Compare commits
17 Commits
70b5d27493
...
backup/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f6592739b | |||
| 900cd28a04 | |||
| 492fba3105 | |||
| 8952bf4205 | |||
| 90f9ec9528 | |||
| 5e56383f6f | |||
| a4c44a3fba | |||
| 158e7291d1 | |||
| 42bd3a7657 | |||
| 4e3a227a79 | |||
| e6a5d4cfae | |||
| d23b4402bf | |||
| 4afb913069 | |||
| b0d3245b9d | |||
| 8ceb212033 | |||
| 0612eda74c | |||
| 49549b6e00 |
8
.sisyphus/notepads/fingerprint-migration/decisions.md
Normal file
8
.sisyphus/notepads/fingerprint-migration/decisions.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Migration Decisions
|
||||||
|
|
||||||
|
- **Library**: `@isaacs/ttlcache` v2.1.4
|
||||||
|
- **Rationale**: Minimal, zero-dependency, specialized for TTL, well-maintained.
|
||||||
|
- **Cache Key**: Fixed string `'fingerprint'` since we only cache one result.
|
||||||
|
- **Max Items**: 1 (singleton fingerprint).
|
||||||
|
- **Default TTL**: 10 minutes (compatible with previous implementation).
|
||||||
|
|
||||||
8
.sisyphus/notepads/fingerprint-migration/learnings.md
Normal file
8
.sisyphus/notepads/fingerprint-migration/learnings.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## @isaacs/ttlcache Migration Learnings
|
||||||
|
|
||||||
|
- Successfully replaced manual TTL cache with `@isaacs/ttlcache` in `fingerprint.ts`.
|
||||||
|
- Use `catalog:` for dependency management in the Bun monorepo.
|
||||||
|
- `TTLCache` does not handle in-flight request deduplication, so the `inFlight` Promise pattern was preserved.
|
||||||
|
- Alphabetical sorting in `package.json` catalog is important for consistency.
|
||||||
|
- Biome handles import organization and formatting; `bun fix` should be run after manual edits.
|
||||||
|
|
||||||
3
.sisyphus/notepads/license-activation/decisions.md
Normal file
3
.sisyphus/notepads/license-activation/decisions.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Renamed `db` middleware to `dbProvider` as requested, while keeping `db` as an alias to avoid breaking existing routers.
|
||||||
4
.sisyphus/notepads/license-activation/issues.md
Normal file
4
.sisyphus/notepads/license-activation/issues.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
## Issues encountered
|
||||||
|
|
||||||
|
- `db:migrate` failed due to `device_info` table already existing in the local SQLite database. Resolved by using `db:push` which is suitable for dev environments.
|
||||||
|
- `lsp_diagnostics` was not available, used `bun typecheck` (`tsc --noEmit`) instead.
|
||||||
7
.sisyphus/notepads/license-activation/learnings.md
Normal file
7
.sisyphus/notepads/license-activation/learnings.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## License Activation Implementation
|
||||||
|
|
||||||
|
- Created `license_activation` table for better semantic clarity.
|
||||||
|
- Implemented `ensureLicenseActivationInitialized` with UPSERT logic and singleton pattern.
|
||||||
|
- Added ORPC endpoints `license.getActivation` and `license.activate`.
|
||||||
|
- Used `db:push` to handle migration in dev environment when conflicts occur.
|
||||||
|
- Fixed type mismatch in `device.router.ts` where `Date` was returned instead of `number` for timestamp fields.
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
# Electrobun Desktop: Production Mode via Child Process Architecture
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
> **Quick Summary**: Redesign the Electrobun desktop app to support production mode by spawning the TanStack Start server as a child process. Currently only dev mode works (hardcoded `localhost:3000`). The desktop will detect dev/prod mode, spawn the server with `PORT=0` in prod, parse the actual port from stdout, and open the BrowserWindow.
|
|
||||||
>
|
|
||||||
> **Deliverables**:
|
|
||||||
> - Rewritten `apps/desktop/src/bun/index.ts` with dev/prod mode support
|
|
||||||
> - Updated `apps/desktop/electrobun.config.ts` with `build.copy` and platform configs
|
|
||||||
> - Cross-workspace build dependency in turbo pipeline
|
|
||||||
> - Updated `apps/desktop/AGENTS.md` reflecting new architecture
|
|
||||||
>
|
|
||||||
> **Estimated Effort**: Medium
|
|
||||||
> **Parallel Execution**: YES - 2 waves
|
|
||||||
> **Critical Path**: Tasks 1,2,3 (parallel) → Task 4 → Task 5
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
### Original Request
|
|
||||||
Redesign the Electrobun desktop app to support production mode. The desktop app should spawn the TanStack Start server as a child process, detect dev vs prod mode at runtime, use system-assigned ports for security, and handle server lifecycle (crash, quit).
|
|
||||||
|
|
||||||
### Confirmed Decisions
|
|
||||||
- **Architecture**: Desktop spawns server as child process via `Bun.spawn`
|
|
||||||
- **Server artifact**: `.output/server/index.mjs` (not compiled binary) — Electrobun already bundles Bun
|
|
||||||
- **Port strategy**: `PORT=0` (system-assigned), `HOST=127.0.0.1`
|
|
||||||
- **Dev/Prod detection**: `process.env.ELECTROBUN_BUILD_ENV` (see Defaults Applied below)
|
|
||||||
- **Target platforms**: All (Linux, macOS, Windows)
|
|
||||||
- **DATABASE_URL**: Pass-through via env var, no special handling
|
|
||||||
- **Crash handling**: MVP — log error to stderr, exit process
|
|
||||||
|
|
||||||
### Research Findings
|
|
||||||
|
|
||||||
**Electrobun APIs (verified from source code):**
|
|
||||||
- `build.copy` supports paths outside the project directory (e.g., `../server/.output`). Source paths are resolved relative to the project root. Destinations map into `Resources/app/` in the bundle.
|
|
||||||
- `PATHS` exported from `electrobun/bun` provides `RESOURCES_FOLDER` (absolute path to `Resources/`) and `VIEWS_FOLDER` (`Resources/app/views/`).
|
|
||||||
- `process.execPath` in a bundled Electrobun app points to the bundled Bun binary.
|
|
||||||
- `Electrobun.events.on('before-quit', callback)` fires before app quit. Callback receives an event with `response({ allow: false })` to cancel quit.
|
|
||||||
- `ELECTROBUN_BUILD_ENV` is set by the Electrobun CLI: `"dev"` for `electrobun dev`, `"stable"` for `electrobun build --env=stable`.
|
|
||||||
|
|
||||||
**Server startup behavior (verified from built output):**
|
|
||||||
- `.output/server/index.mjs` uses `Bun.serve` via the `h3+srvx` adapter (Nitro bun preset).
|
|
||||||
- Startup log format: `➜ Listening on: http://<address>:<port>/`
|
|
||||||
- The log uses the actual assigned address/port, not the requested one. So `PORT=0` will log the real port.
|
|
||||||
- `DATABASE_URL` is validated at startup via Zod (`@t3-oss/env-core`). Missing = immediate crash.
|
|
||||||
- The `.output/server/` directory contains `index.mjs` plus `_libs/` with bundled dependencies.
|
|
||||||
|
|
||||||
**Turbo pipeline:**
|
|
||||||
- Root `turbo.json` has `build.dependsOn: ["^build"]` which only builds workspace *dependencies*.
|
|
||||||
- Desktop does NOT depend on server in `package.json`, so `^build` won't trigger server build.
|
|
||||||
- Need explicit cross-workspace dependency via desktop's `turbo.json`.
|
|
||||||
|
|
||||||
### Metis Review
|
|
||||||
**Identified Gaps** (addressed):
|
|
||||||
- Dev/prod detection mechanism: Switched from custom `ELECTROBUN_DEV` to built-in `ELECTROBUN_BUILD_ENV`
|
|
||||||
- Server startup timeout: Added explicit timeout with error reporting
|
|
||||||
- Port parsing failure: Plan includes fallback and error handling
|
|
||||||
- Server crash during runtime: Watching `subprocess.exited` promise
|
|
||||||
- `cwd` for spawned server: Must set to server directory for relative import resolution
|
|
||||||
- Cross-platform considerations: `ELECTROBUN_BUILD_ENV` works everywhere (no `cross-env` needed)
|
|
||||||
|
|
||||||
### Unknowns Resolved
|
|
||||||
|
|
||||||
| Unknown | Resolution |
|
|
||||||
|---------|------------|
|
|
||||||
| Does `build.copy` support paths outside project? | **YES** — uses `cpSync` with source resolved from project root. `../server/.output` works. |
|
|
||||||
| Runtime API for resolving bundled resource paths? | **`PATHS.RESOURCES_FOLDER`** from `electrobun/bun`. Copied files land in `Resources/app/{dest}/`. |
|
|
||||||
| Does Nitro log actual port with PORT=0? | **YES** — format: `➜ Listening on: http://<addr>:<port>/` via h3+srvx adapter. |
|
|
||||||
| How does Electrobun detect dev mode? | **`ELECTROBUN_BUILD_ENV`** env var set by CLI. Values: `dev`, `canary`, `stable`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Work Objectives
|
|
||||||
|
|
||||||
### Core Objective
|
|
||||||
Enable the Electrobun desktop app to run in production mode by spawning the TanStack Start server as a managed child process, while preserving existing dev mode behavior.
|
|
||||||
|
|
||||||
### Concrete Deliverables
|
|
||||||
- `apps/desktop/src/bun/index.ts` — Complete rewrite with dual-mode support
|
|
||||||
- `apps/desktop/electrobun.config.ts` — `build.copy` + all-platform configs
|
|
||||||
- `apps/desktop/turbo.json` — Cross-workspace build dependency
|
|
||||||
- `apps/desktop/AGENTS.md` — Accurate documentation of new architecture
|
|
||||||
|
|
||||||
### Definition of Done
|
|
||||||
- [ ] `bun typecheck` passes from monorepo root (zero errors)
|
|
||||||
- [ ] `bun build` from root succeeds: server builds first, then desktop bundles server output
|
|
||||||
- [ ] `bun dev` from root still starts both apps (dev mode preserved)
|
|
||||||
- [ ] Desktop `index.ts` has zero hardcoded URLs (all dynamic)
|
|
||||||
|
|
||||||
### Must Have
|
|
||||||
- Dev mode: poll external `localhost:3000`, open window when ready (existing behavior, refactored)
|
|
||||||
- Prod mode: spawn server via `Bun.spawn`, parse port from stdout, open window
|
|
||||||
- Server bound to `127.0.0.1` only (no network exposure)
|
|
||||||
- `PORT=0` for system-assigned port (no conflicts)
|
|
||||||
- Server process killed on app quit (via `before-quit` event)
|
|
||||||
- Server crash detection (watch `exited` promise, log error, exit app)
|
|
||||||
- Startup timeout with clear error message
|
|
||||||
- Server `cwd` set to its own directory (for relative import resolution)
|
|
||||||
- `DATABASE_URL` passed through from parent environment
|
|
||||||
|
|
||||||
### Must NOT Have (Guardrails)
|
|
||||||
- No hardcoded port numbers (not even 3000 — use a named constant `DEV_SERVER_URL`)
|
|
||||||
- No `as any`, `@ts-ignore`, or `@ts-expect-error`
|
|
||||||
- No empty catch blocks — always handle or re-throw
|
|
||||||
- No `npm`, `npx`, `node` — Bun only
|
|
||||||
- No manual `useMemo`/`useCallback` (not relevant here, but per project rules)
|
|
||||||
- No suppressed type errors — fix them properly
|
|
||||||
- No custom env var for dev detection — use built-in `ELECTROBUN_BUILD_ENV`
|
|
||||||
- No compiled server binary — use `.output/server/index.mjs` with bundled Bun
|
|
||||||
- Do NOT edit `apps/server/` — only `apps/desktop/` files change
|
|
||||||
- Do NOT add `@furtherverse/server` as a package dependency of desktop (use turbo cross-workspace dependency instead)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Strategy
|
|
||||||
|
|
||||||
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
|
|
||||||
>
|
|
||||||
> ALL tasks in this plan MUST be verifiable WITHOUT any human action.
|
|
||||||
> Every criterion is verified by running a command or using a tool.
|
|
||||||
|
|
||||||
### Test Decision
|
|
||||||
- **Infrastructure exists**: NO (no test framework in this project)
|
|
||||||
- **Automated tests**: NO (per project state — `AGENTS.md` says "No test framework configured yet")
|
|
||||||
- **Framework**: None
|
|
||||||
- **Agent-Executed QA**: ALWAYS (mandatory for all tasks)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Strategy
|
|
||||||
|
|
||||||
### Parallel Execution Waves
|
|
||||||
|
|
||||||
```
|
|
||||||
Wave 1 (Start Immediately — all independent, different files):
|
|
||||||
├── Task 1: Update electrobun.config.ts (build.copy + platform configs)
|
|
||||||
├── Task 2: Update turbo.json (cross-workspace build dependency)
|
|
||||||
└── Task 3: Rewrite index.ts (complete dev/prod mode implementation)
|
|
||||||
|
|
||||||
Wave 2 (After Wave 1 — needs final state of all files):
|
|
||||||
├── Task 4: Rewrite AGENTS.md (documentation reflecting new architecture)
|
|
||||||
└── Task 5: End-to-end verification (typecheck + build pipeline)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependency Matrix
|
|
||||||
|
|
||||||
| Task | Depends On | Blocks | Can Parallelize With |
|
|
||||||
|------|------------|--------|---------------------|
|
|
||||||
| 1 | None | 4, 5 | 2, 3 |
|
|
||||||
| 2 | None | 4, 5 | 1, 3 |
|
|
||||||
| 3 | None | 4, 5 | 1, 2 |
|
|
||||||
| 4 | 1, 2, 3 | 5 | None |
|
|
||||||
| 5 | 1, 2, 3, 4 | None | None (final) |
|
|
||||||
|
|
||||||
### Agent Dispatch Summary
|
|
||||||
|
|
||||||
| Wave | Tasks | Recommended Agents |
|
|
||||||
|------|-------|-------------------|
|
|
||||||
| 1 | 1, 2, 3 | Tasks 1,2: `delegate_task(category="quick")`. Task 3: `delegate_task(category="unspecified-high")` |
|
|
||||||
| 2 | 4, 5 | Task 4: `delegate_task(category="writing")`. Task 5: `delegate_task(category="quick")` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TODOs
|
|
||||||
|
|
||||||
- [ ] 1. Update `apps/desktop/electrobun.config.ts` — Add build.copy and platform configs
|
|
||||||
|
|
||||||
**What to do**:
|
|
||||||
- Add `build.copy` to include the server's Nitro build output in the desktop bundle:
|
|
||||||
```typescript
|
|
||||||
copy: {
|
|
||||||
'../server/.output': 'server-output',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
This copies `apps/server/.output/` (the entire Nitro build output) into `Resources/app/server-output/` in the Electrobun bundle. The full server entry point will be at `Resources/app/server-output/server/index.mjs`.
|
|
||||||
- Add `macOS` platform config block (currently only `linux` exists):
|
|
||||||
```typescript
|
|
||||||
macOS: {
|
|
||||||
bundleCEF: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Add `windows` platform config block:
|
|
||||||
```typescript
|
|
||||||
windows: {
|
|
||||||
bundleCEF: true,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Verify the exact property names by checking Electrobun's `ElectrobunConfig` type definition. The `linux` block already uses `bundleCEF: true`, so follow the same pattern for other platforms. If the type doesn't support `macOS`/`windows` yet, skip those and leave a `// TODO:` comment explaining what's needed.
|
|
||||||
- Preserve existing config values exactly (app name, identifier, version, bun entrypoint, linux config).
|
|
||||||
|
|
||||||
**Must NOT do**:
|
|
||||||
- Do not change the app name, identifier, or version
|
|
||||||
- Do not change the bun entrypoint path
|
|
||||||
- Do not remove the existing `linux` config
|
|
||||||
- Do not add dependencies or scripts
|
|
||||||
|
|
||||||
**Recommended Agent Profile**:
|
|
||||||
- **Category**: `quick`
|
|
||||||
- Reason: Single file, small config change, clear specification
|
|
||||||
- **Skills**: `[]`
|
|
||||||
- No specialized skills needed — straightforward TypeScript config edit
|
|
||||||
- **Skills Evaluated but Omitted**:
|
|
||||||
- `frontend-ui-ux`: No UI work involved
|
|
||||||
|
|
||||||
**Parallelization**:
|
|
||||||
- **Can Run In Parallel**: YES
|
|
||||||
- **Parallel Group**: Wave 1 (with Tasks 2, 3)
|
|
||||||
- **Blocks**: Tasks 4, 5
|
|
||||||
- **Blocked By**: None (can start immediately)
|
|
||||||
|
|
||||||
**References**:
|
|
||||||
|
|
||||||
**Pattern References** (existing code to follow):
|
|
||||||
- `apps/desktop/electrobun.config.ts` — Current config structure. The `linux.bundleCEF: true` pattern should be replicated for other platforms. The `build.bun.entrypoint` key shows where build config lives.
|
|
||||||
|
|
||||||
**API/Type References** (contracts to implement against):
|
|
||||||
- The `ElectrobunConfig` type from `electrobun` — imported via `import type { ElectrobunConfig } from 'electrobun'`. Check its definition (likely in `node_modules/electrobun/`) to verify exact property names for `copy`, `macOS`, `windows`.
|
|
||||||
|
|
||||||
**External References**:
|
|
||||||
- Electrobun `build.copy` syntax: copies source (relative to project root) into `Resources/app/{dest}/` in the bundle. Uses `cpSync` with `dereference: true`.
|
|
||||||
|
|
||||||
**WHY Each Reference Matters**:
|
|
||||||
- `electrobun.config.ts`: You're editing this file — need to know its current shape to preserve existing values
|
|
||||||
- `ElectrobunConfig` type: Must match the type definition exactly — don't guess property names
|
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
|
||||||
- [ ] `build.copy` key exists with `'../server/.output': 'server-output'` mapping
|
|
||||||
- [ ] Platform configs added for all three platforms (or TODO comments if types don't support them)
|
|
||||||
- [ ] Existing config values unchanged (app.name = 'Desktop', etc.)
|
|
||||||
- [ ] File passes `bun typecheck` (no type errors)
|
|
||||||
|
|
||||||
**Agent-Executed QA Scenarios:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Scenario: Config file is valid TypeScript with correct types
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: None
|
|
||||||
Steps:
|
|
||||||
1. Run: bun typecheck (from apps/desktop/)
|
|
||||||
2. Assert: Exit code 0
|
|
||||||
3. Assert: No errors mentioning electrobun.config.ts
|
|
||||||
Expected Result: TypeScript compilation succeeds
|
|
||||||
Evidence: Terminal output captured
|
|
||||||
|
|
||||||
Scenario: build.copy key has correct structure
|
|
||||||
Tool: Bash (grep)
|
|
||||||
Preconditions: File has been edited
|
|
||||||
Steps:
|
|
||||||
1. Read apps/desktop/electrobun.config.ts
|
|
||||||
2. Assert: Contains '../server/.output'
|
|
||||||
3. Assert: Contains 'server-output'
|
|
||||||
4. Assert: File still contains 'satisfies ElectrobunConfig'
|
|
||||||
Expected Result: Config has copy mapping and type annotation
|
|
||||||
Evidence: File contents
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit**: YES (groups with 2)
|
|
||||||
- Message: `feat(desktop): add build.copy for server bundle and platform configs`
|
|
||||||
- Files: `apps/desktop/electrobun.config.ts`
|
|
||||||
- Pre-commit: `bun typecheck` (from `apps/desktop/`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] 2. Update `apps/desktop/turbo.json` — Add cross-workspace build dependency
|
|
||||||
|
|
||||||
**What to do**:
|
|
||||||
- Add `dependsOn` to the existing `build` task to ensure the server builds before the desktop:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tasks": {
|
|
||||||
"build": {
|
|
||||||
"dependsOn": ["@furtherverse/server#build"],
|
|
||||||
"outputs": ["build/**", "artifacts/**"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- This tells Turbo: "before running `build` for `@furtherverse/desktop`, first run `build` for `@furtherverse/server`."
|
|
||||||
- This ensures `apps/server/.output/` exists when `electrobun build` runs and tries to `build.copy` from `../server/.output`.
|
|
||||||
- Preserve the existing `outputs` array exactly.
|
|
||||||
|
|
||||||
**Must NOT do**:
|
|
||||||
- Do not modify the root `turbo.json` — only `apps/desktop/turbo.json`
|
|
||||||
- Do not remove existing `outputs`
|
|
||||||
- Do not add other tasks or change other config
|
|
||||||
|
|
||||||
**Recommended Agent Profile**:
|
|
||||||
- **Category**: `quick`
|
|
||||||
- Reason: Single file, one-line JSON change
|
|
||||||
- **Skills**: `[]`
|
|
||||||
- No specialized skills needed
|
|
||||||
- **Skills Evaluated but Omitted**:
|
|
||||||
- `git-master`: Commit will be grouped with Task 1
|
|
||||||
|
|
||||||
**Parallelization**:
|
|
||||||
- **Can Run In Parallel**: YES
|
|
||||||
- **Parallel Group**: Wave 1 (with Tasks 1, 3)
|
|
||||||
- **Blocks**: Tasks 4, 5
|
|
||||||
- **Blocked By**: None (can start immediately)
|
|
||||||
|
|
||||||
**References**:
|
|
||||||
|
|
||||||
**Pattern References** (existing code to follow):
|
|
||||||
- `apps/desktop/turbo.json` — Current file with `build.outputs` already defined. You're adding `dependsOn` alongside it.
|
|
||||||
- `turbo.json` (root) — Shows existing turbo patterns like `build.dependsOn: ["^build"]`. The root already uses `^build` for workspace dependencies, but since desktop doesn't list server as a package dependency, we need an explicit cross-workspace reference.
|
|
||||||
|
|
||||||
**API/Type References**:
|
|
||||||
- Turbo `dependsOn` syntax: `"@furtherverse/server#build"` means "run the `build` task in the `@furtherverse/server` workspace".
|
|
||||||
|
|
||||||
**Documentation References**:
|
|
||||||
- `apps/server/package.json` — The package name is `@furtherverse/server` (verify this is the exact name used in the `dependsOn` reference).
|
|
||||||
|
|
||||||
**WHY Each Reference Matters**:
|
|
||||||
- `apps/desktop/turbo.json`: You're editing this file — preserve existing outputs
|
|
||||||
- `apps/server/package.json`: Need exact package name for the cross-workspace reference
|
|
||||||
- Root `turbo.json`: Context for existing turbo patterns in this project
|
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
|
||||||
- [ ] `apps/desktop/turbo.json` has `dependsOn: ["@furtherverse/server#build"]` in the build task
|
|
||||||
- [ ] Existing `outputs` array is preserved
|
|
||||||
- [ ] Valid JSON (no syntax errors)
|
|
||||||
|
|
||||||
**Agent-Executed QA Scenarios:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Scenario: turbo.json is valid JSON with correct structure
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: File has been edited
|
|
||||||
Steps:
|
|
||||||
1. Run: bun -e "JSON.parse(require('fs').readFileSync('apps/desktop/turbo.json', 'utf8'))"
|
|
||||||
2. Assert: Exit code 0 (valid JSON)
|
|
||||||
3. Read the file and verify structure contains both dependsOn and outputs
|
|
||||||
Expected Result: Valid JSON with both keys present
|
|
||||||
Evidence: Terminal output captured
|
|
||||||
|
|
||||||
Scenario: Turbo resolves the cross-workspace dependency
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: turbo.json updated
|
|
||||||
Steps:
|
|
||||||
1. Run: bunx turbo build --dry-run --filter=@furtherverse/desktop (from monorepo root)
|
|
||||||
2. Assert: Output shows @furtherverse/server#build runs BEFORE @furtherverse/desktop#build
|
|
||||||
Expected Result: Server build is listed as a dependency in the dry-run output
|
|
||||||
Evidence: Terminal output showing task execution order
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit**: YES (groups with 1)
|
|
||||||
- Message: `feat(desktop): add build.copy for server bundle and platform configs`
|
|
||||||
- Files: `apps/desktop/turbo.json`
|
|
||||||
- Pre-commit: Valid JSON check
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] 3. Rewrite `apps/desktop/src/bun/index.ts` — Complete dev/prod mode implementation
|
|
||||||
|
|
||||||
**What to do**:
|
|
||||||
|
|
||||||
This is the core task. Completely rewrite `index.ts` to support both dev and prod modes. The new file should have this structure:
|
|
||||||
|
|
||||||
**A. Imports and Constants**:
|
|
||||||
```typescript
|
|
||||||
import Electrobun, { BrowserWindow } from 'electrobun/bun'
|
|
||||||
// Import PATHS — verify exact import syntax from electrobun/bun type definitions
|
|
||||||
// It may be: import { PATHS } from 'electrobun/bun'
|
|
||||||
// Or it may be on the Electrobun default export: Electrobun.PATHS
|
|
||||||
// CHECK the type definitions in node_modules/electrobun/ before writing
|
|
||||||
import { join, dirname } from 'path'
|
|
||||||
|
|
||||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
|
||||||
const SERVER_READY_TIMEOUT_MS = 30_000
|
|
||||||
const PORT_PATTERN = /Listening on:?\s*https?:\/\/[^\s:]+:(\d+)/
|
|
||||||
```
|
|
||||||
|
|
||||||
**B. `isDev()` function**:
|
|
||||||
- Check `process.env.ELECTROBUN_BUILD_ENV === 'dev'`
|
|
||||||
- If `ELECTROBUN_BUILD_ENV` is not set, default to `true` (dev mode) — safe fallback
|
|
||||||
- Return a boolean
|
|
||||||
|
|
||||||
**C. `getServerEntryPath()` function**:
|
|
||||||
- Use `PATHS.RESOURCES_FOLDER` (or equivalent) to resolve the bundled server entry
|
|
||||||
- Path: `join(PATHS.RESOURCES_FOLDER, 'app', 'server-output', 'server', 'index.mjs')`
|
|
||||||
- **IMPORTANT**: Verify `PATHS.RESOURCES_FOLDER` points to `Resources/` and that `build.copy` destinations land in `Resources/app/`. If the pathing is different, adjust accordingly. The executor MUST verify by checking Electrobun's source or type definitions.
|
|
||||||
|
|
||||||
**D. `waitForServer(url, timeoutMs)` function** (preserved from current code):
|
|
||||||
- Polls a URL with `fetch` HEAD requests
|
|
||||||
- Returns `true` when server responds with `response.ok`
|
|
||||||
- Returns `false` on timeout
|
|
||||||
- Uses `Bun.sleep(100)` between attempts
|
|
||||||
- Catches fetch errors silently (server not up yet)
|
|
||||||
|
|
||||||
**E. `spawnServer()` function** (NEW — the critical piece):
|
|
||||||
- Returns a `Promise<{ process: Subprocess; url: string }>`
|
|
||||||
- Implementation:
|
|
||||||
1. Resolve the server entry path via `getServerEntryPath()`
|
|
||||||
2. Resolve the server directory via `dirname(serverEntryPath)` — used as `cwd`
|
|
||||||
3. Spawn with `Bun.spawn`:
|
|
||||||
```typescript
|
|
||||||
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
|
|
||||||
cwd: serverDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PORT: '0',
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
},
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
4. Read stdout as a stream to find the port:
|
|
||||||
- Use `serverProc.stdout` (a `ReadableStream<Uint8Array>`)
|
|
||||||
- Create a reader, accumulate chunks into a text buffer
|
|
||||||
- Test buffer against `PORT_PATTERN` regex after each chunk
|
|
||||||
- When match found: extract port, resolve promise with `{ process: serverProc, url: 'http://127.0.0.1:${port}' }`
|
|
||||||
5. Implement a timeout:
|
|
||||||
- Use `setTimeout` to reject the promise after `SERVER_READY_TIMEOUT_MS`
|
|
||||||
- On timeout, kill the server process before rejecting
|
|
||||||
6. Handle early exit:
|
|
||||||
- If stdout ends (stream done) before port is found, reject with error
|
|
||||||
- Include any stderr output in the error message for debugging
|
|
||||||
|
|
||||||
**F. `main()` async function**:
|
|
||||||
- Log startup message
|
|
||||||
- Branch on `isDev()`:
|
|
||||||
- **Dev mode**:
|
|
||||||
1. Log: "Dev mode: waiting for external server..."
|
|
||||||
2. Call `waitForServer(DEV_SERVER_URL)`
|
|
||||||
3. If timeout: log error with instructions (`"Run: bun dev in apps/server"`), `process.exit(1)`
|
|
||||||
4. Set `serverUrl = DEV_SERVER_URL`
|
|
||||||
- **Prod mode**:
|
|
||||||
1. Log: "Production mode: starting embedded server..."
|
|
||||||
2. Call `spawnServer()`
|
|
||||||
3. If error: log error, `process.exit(1)`
|
|
||||||
4. Store returned `process` and `url`
|
|
||||||
- Create `BrowserWindow` with the resolved `serverUrl`:
|
|
||||||
```typescript
|
|
||||||
new BrowserWindow({
|
|
||||||
title: 'Furtherverse',
|
|
||||||
url: serverUrl,
|
|
||||||
frame: { x: 100, y: 100, width: 1200, height: 800 },
|
|
||||||
renderer: 'cef',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
- Register lifecycle handlers:
|
|
||||||
- `Electrobun.events.on('before-quit', ...)`: Kill server process if it exists
|
|
||||||
- Watch `serverProcess.exited` (if in prod mode): When server exits unexpectedly, log the exit code and stderr, then `process.exit(1)`
|
|
||||||
|
|
||||||
**G. Top-level execution**:
|
|
||||||
```typescript
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error('Failed to start:', error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical implementation details**:
|
|
||||||
- The `PORT_PATTERN` regex must handle multiple log formats:
|
|
||||||
- `➜ Listening on: http://localhost:54321/` (srvx format)
|
|
||||||
- `Listening on http://127.0.0.1:54321` (node-server format)
|
|
||||||
- `Listening on http://[::]:54321` (IPv6 format)
|
|
||||||
- The regex `/Listening on:?\s*https?:\/\/[^\s:]+:(\d+)/` captures the port from all these formats.
|
|
||||||
- `cwd` MUST be set to the server directory (`dirname(serverEntryPath)`), not the app root. Nitro resolves internal `_libs/` imports relative to its directory.
|
|
||||||
- `process.execPath` in an Electrobun bundle points to the bundled Bun binary — this is what runs the server.
|
|
||||||
- `stderr: 'pipe'` — capture stderr for crash diagnostics but don't block on it during startup.
|
|
||||||
|
|
||||||
**Must NOT do**:
|
|
||||||
- Do not hardcode port numbers anywhere (use `PORT=0` and parse result)
|
|
||||||
- Do not use `as any` or type assertions to work around issues
|
|
||||||
- Do not use `child_process` module — use `Bun.spawn` (native Bun API)
|
|
||||||
- Do not bind server to `0.0.0.0` — always use `127.0.0.1`
|
|
||||||
- Do not leave the `waitForServer` function unused in dev mode
|
|
||||||
- Do not use synchronous I/O for stdout reading
|
|
||||||
|
|
||||||
**Recommended Agent Profile**:
|
|
||||||
- **Category**: `unspecified-high`
|
|
||||||
- Reason: Complex async logic (stream parsing, subprocess lifecycle, timeout management), multiple code paths (dev/prod), error handling across process boundaries. This is the architectural centerpiece.
|
|
||||||
- **Skills**: `[]`
|
|
||||||
- No specialized skills needed — pure Bun/TypeScript with Electrobun APIs
|
|
||||||
- **Skills Evaluated but Omitted**:
|
|
||||||
- `frontend-ui-ux`: No UI work — this is backend/process management code
|
|
||||||
- `playwright`: No browser testing needed for this task
|
|
||||||
|
|
||||||
**Parallelization**:
|
|
||||||
- **Can Run In Parallel**: YES
|
|
||||||
- **Parallel Group**: Wave 1 (with Tasks 1, 2)
|
|
||||||
- **Blocks**: Tasks 4, 5
|
|
||||||
- **Blocked By**: None (can start immediately — edits a different file from Tasks 1, 2)
|
|
||||||
|
|
||||||
**References**:
|
|
||||||
|
|
||||||
**Pattern References** (existing code to follow):
|
|
||||||
- `apps/desktop/src/bun/index.ts` — Current implementation. Preserve the `waitForServer` polling pattern (slightly refactored). Keep the `BrowserWindow` config (title, frame dimensions, renderer). Keep the top-level `main().catch(...)` pattern.
|
|
||||||
- `apps/desktop/src/bun/index.ts:1` — Current import: `import Electrobun, { BrowserWindow } from 'electrobun/bun'`. Extend this to also import `PATHS` (verify exact export name from type definitions).
|
|
||||||
|
|
||||||
**API/Type References** (contracts to implement against):
|
|
||||||
- `electrobun/bun` module — Exports `Electrobun` (default), `BrowserWindow` (named), and `PATHS` (named — verify). Check `node_modules/electrobun/` for exact type definitions.
|
|
||||||
- `Bun.spawn` API — Returns `Subprocess` with `.stdout` (ReadableStream when piped), `.stderr`, `.exited` (Promise<number>), `.kill()`.
|
|
||||||
- `PATHS.RESOURCES_FOLDER` — Absolute path to `Resources/` directory in the bundle. Verify by reading the Paths.ts source in electrobun package.
|
|
||||||
|
|
||||||
**Documentation References**:
|
|
||||||
- `apps/desktop/AGENTS.md` — Mentions production mode architecture (aspirational, but gives intent)
|
|
||||||
|
|
||||||
**External References**:
|
|
||||||
- Electrobun lifecycle events: `Electrobun.events.on('before-quit', callback)` — callback can call `event.response({ allow: false })` to cancel. Source: `electrobun/src/bun/core/Utils.ts`.
|
|
||||||
- Electrobun `PATHS`: Source at `electrobun/src/bun/core/Paths.ts`. Contains `RESOURCES_FOLDER` and `VIEWS_FOLDER`.
|
|
||||||
- Bun `Subprocess` docs: `stdout` is `ReadableStream<Uint8Array>` when `stdout: 'pipe'`.
|
|
||||||
|
|
||||||
**WHY Each Reference Matters**:
|
|
||||||
- Current `index.ts`: Preserving the `waitForServer` pattern, `BrowserWindow` config, and error handling style. You're rewriting this file, so understand what to keep vs. replace.
|
|
||||||
- `electrobun/bun` types: MUST verify `PATHS` export name and shape before using it. Don't assume — check.
|
|
||||||
- `Bun.spawn` API: Core to the entire prod mode implementation. Understand `stdout` stream reading.
|
|
||||||
- Lifecycle events: `before-quit` is where server cleanup happens. Understand the event contract.
|
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
|
||||||
- [ ] File compiles: `bun typecheck` passes (from `apps/desktop/`)
|
|
||||||
- [ ] No hardcoded port numbers (grep for `:3000` — should only appear in `DEV_SERVER_URL` constant)
|
|
||||||
- [ ] `isDev()` function uses `process.env.ELECTROBUN_BUILD_ENV`
|
|
||||||
- [ ] `spawnServer()` uses `PORT=0`, `HOST=127.0.0.1`, `process.execPath`
|
|
||||||
- [ ] `spawnServer()` sets `cwd` to `dirname(serverEntryPath)`
|
|
||||||
- [ ] `before-quit` handler kills server process
|
|
||||||
- [ ] Server crash watcher exists (watches `subprocess.exited`)
|
|
||||||
- [ ] Timeout handling exists in both dev and prod paths
|
|
||||||
- [ ] All Biome rules pass: `bun fix` produces no changes
|
|
||||||
|
|
||||||
**Agent-Executed QA Scenarios:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Scenario: File compiles with zero type errors
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Run: bun typecheck (from apps/desktop/)
|
|
||||||
2. Assert: Exit code 0
|
|
||||||
3. Assert: No errors in output
|
|
||||||
Expected Result: Clean TypeScript compilation
|
|
||||||
Evidence: Terminal output captured
|
|
||||||
|
|
||||||
Scenario: No hardcoded ports outside DEV_SERVER_URL
|
|
||||||
Tool: Bash (grep)
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Search apps/desktop/src/bun/index.ts for literal ':3000'
|
|
||||||
2. Assert: Only occurrence is in the DEV_SERVER_URL constant definition
|
|
||||||
3. Search for literal '3000' — should only appear once
|
|
||||||
Expected Result: Port 3000 only in constant, nowhere else
|
|
||||||
Evidence: Grep output
|
|
||||||
|
|
||||||
Scenario: Code passes Biome lint/format
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Run: bun fix (from apps/desktop/)
|
|
||||||
2. Run: git diff apps/desktop/src/bun/index.ts
|
|
||||||
3. Assert: No diff (bun fix made no changes)
|
|
||||||
Expected Result: Code already conforms to Biome rules
|
|
||||||
Evidence: Empty git diff
|
|
||||||
|
|
||||||
Scenario: Required patterns present in source
|
|
||||||
Tool: Bash (grep)
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Grep for 'ELECTROBUN_BUILD_ENV' — Assert: found
|
|
||||||
2. Grep for 'Bun.spawn' — Assert: found
|
|
||||||
3. Grep for 'process.execPath' — Assert: found
|
|
||||||
4. Grep for 'PORT.*0' — Assert: found
|
|
||||||
5. Grep for '127.0.0.1' — Assert: found
|
|
||||||
6. Grep for 'before-quit' — Assert: found
|
|
||||||
7. Grep for '.exited' — Assert: found (crash watcher)
|
|
||||||
8. Grep for 'dirname' — Assert: found (cwd for server)
|
|
||||||
Expected Result: All required patterns present
|
|
||||||
Evidence: Grep results for each pattern
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit**: YES
|
|
||||||
- Message: `feat(desktop): implement production mode with child process server`
|
|
||||||
- Files: `apps/desktop/src/bun/index.ts`
|
|
||||||
- Pre-commit: `bun typecheck && bun fix`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] 4. Rewrite `apps/desktop/AGENTS.md` — Document new architecture
|
|
||||||
|
|
||||||
**What to do**:
|
|
||||||
- Completely rewrite `AGENTS.md` to reflect the actual implemented architecture
|
|
||||||
- Document:
|
|
||||||
- **Architecture overview**: Desktop spawns server as child process in prod, connects to external server in dev
|
|
||||||
- **Dev mode**: How it works (polls localhost:3000, requires server running separately)
|
|
||||||
- **Prod mode**: How it works (spawns server from bundle, PORT=0, parses port from stdout)
|
|
||||||
- **Environment detection**: `ELECTROBUN_BUILD_ENV` values (`dev`, `canary`, `stable`)
|
|
||||||
- **Build pipeline**: Server must build before desktop (turbo dependency), `build.copy` bundles output
|
|
||||||
- **Key files**: `src/bun/index.ts` (main process), `electrobun.config.ts` (build config)
|
|
||||||
- **Environment variables**: `DATABASE_URL` (required, passed to server), `ELECTROBUN_BUILD_ENV` (auto-set by CLI)
|
|
||||||
- **Server lifecycle**: Spawned on start, killed on quit, crash = exit
|
|
||||||
- **Commands**: `bun dev`, `bun build`, `bun typecheck`, `bun fix`
|
|
||||||
- Follow the style and conventions of the root `AGENTS.md` and `apps/server/AGENTS.md`
|
|
||||||
- Be factual — only document what actually exists, not aspirational features
|
|
||||||
|
|
||||||
**Must NOT do**:
|
|
||||||
- Do not document features that don't exist
|
|
||||||
- Do not copy content from the server's AGENTS.md verbatim
|
|
||||||
- Do not include implementation details that belong in code comments
|
|
||||||
|
|
||||||
**Recommended Agent Profile**:
|
|
||||||
- **Category**: `writing`
|
|
||||||
- Reason: Documentation task requiring clear technical writing
|
|
||||||
- **Skills**: `[]`
|
|
||||||
- No specialized skills needed
|
|
||||||
- **Skills Evaluated but Omitted**:
|
|
||||||
- `frontend-ui-ux`: Not a UI task
|
|
||||||
|
|
||||||
**Parallelization**:
|
|
||||||
- **Can Run In Parallel**: NO
|
|
||||||
- **Parallel Group**: Wave 2
|
|
||||||
- **Blocks**: Task 5
|
|
||||||
- **Blocked By**: Tasks 1, 2, 3 (needs to know final state of all files)
|
|
||||||
|
|
||||||
**References**:
|
|
||||||
|
|
||||||
**Pattern References** (existing code to follow):
|
|
||||||
- `AGENTS.md` (root) — Follow same structure: Overview, Build Commands, Code Style, Directory Structure sections
|
|
||||||
- `apps/server/AGENTS.md` — Follow same style for app-specific documentation. Use this as a template for tone and detail level.
|
|
||||||
|
|
||||||
**Content References** (what to document):
|
|
||||||
- `apps/desktop/src/bun/index.ts` — The rewritten file (Task 3 output). Document its behavior, not its code.
|
|
||||||
- `apps/desktop/electrobun.config.ts` — The updated config (Task 1 output). Document build.copy and platform configs.
|
|
||||||
- `apps/desktop/turbo.json` — The updated turbo config (Task 2 output). Document the build dependency.
|
|
||||||
|
|
||||||
**WHY Each Reference Matters**:
|
|
||||||
- Root `AGENTS.md`: Template for documentation style
|
|
||||||
- Server `AGENTS.md`: Template for app-specific docs
|
|
||||||
- All Task 1-3 outputs: The actual implemented behavior that must be accurately documented
|
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
|
||||||
- [ ] File exists and is valid Markdown
|
|
||||||
- [ ] Documents dev mode behavior accurately
|
|
||||||
- [ ] Documents prod mode behavior accurately
|
|
||||||
- [ ] Documents `ELECTROBUN_BUILD_ENV` mechanism
|
|
||||||
- [ ] Documents build pipeline (server → desktop dependency)
|
|
||||||
- [ ] Documents `DATABASE_URL` requirement
|
|
||||||
- [ ] Does NOT mention features that don't exist
|
|
||||||
- [ ] Follows conventions from root `AGENTS.md`
|
|
||||||
|
|
||||||
**Agent-Executed QA Scenarios:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Scenario: AGENTS.md contains all required sections
|
|
||||||
Tool: Bash (grep)
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Grep for 'dev' or 'Dev' — Assert: found (dev mode documented)
|
|
||||||
2. Grep for 'prod' or 'Prod' or 'production' — Assert: found (prod mode documented)
|
|
||||||
3. Grep for 'ELECTROBUN_BUILD_ENV' — Assert: found
|
|
||||||
4. Grep for 'DATABASE_URL' — Assert: found
|
|
||||||
5. Grep for 'child process' or 'spawn' — Assert: found (architecture documented)
|
|
||||||
6. Grep for 'bun dev' — Assert: found (commands documented)
|
|
||||||
7. Grep for 'bun build' — Assert: found (commands documented)
|
|
||||||
Expected Result: All key topics are covered
|
|
||||||
Evidence: Grep results
|
|
||||||
|
|
||||||
Scenario: No aspirational/unimplemented features documented
|
|
||||||
Tool: Bash (grep)
|
|
||||||
Preconditions: File has been rewritten
|
|
||||||
Steps:
|
|
||||||
1. Grep for 'TODO' or 'planned' or 'future' or 'coming soon' — Assert: not found (or minimal)
|
|
||||||
2. Grep for 'auto-update' — Assert: not found (not implemented)
|
|
||||||
3. Grep for 'tray' — Assert: not found (not implemented)
|
|
||||||
Expected Result: Only implemented features documented
|
|
||||||
Evidence: Grep results showing no aspirational content
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit**: YES
|
|
||||||
- Message: `docs(desktop): rewrite AGENTS.md to reflect production mode architecture`
|
|
||||||
- Files: `apps/desktop/AGENTS.md`
|
|
||||||
- Pre-commit: None (Markdown file)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [ ] 5. End-to-end verification — Typecheck and build pipeline
|
|
||||||
|
|
||||||
**What to do**:
|
|
||||||
- Run full monorepo typecheck to ensure no type errors were introduced
|
|
||||||
- Run full monorepo build to verify:
|
|
||||||
1. Server builds first (produces `.output/`)
|
|
||||||
2. Desktop builds second (copies server output into bundle)
|
|
||||||
3. No build errors
|
|
||||||
- Run Biome formatting/linting check on all changed files
|
|
||||||
- Verify dev mode still works conceptually (no runtime test — just verify the code path exists)
|
|
||||||
|
|
||||||
**Must NOT do**:
|
|
||||||
- Do not fix issues in server code — only desktop code
|
|
||||||
- Do not modify any files unless fixing issues found during verification
|
|
||||||
- Do not skip any verification step
|
|
||||||
|
|
||||||
**Recommended Agent Profile**:
|
|
||||||
- **Category**: `quick`
|
|
||||||
- Reason: Running commands and checking output, no creative work
|
|
||||||
- **Skills**: `[]`
|
|
||||||
- No specialized skills needed
|
|
||||||
- **Skills Evaluated but Omitted**:
|
|
||||||
- `playwright`: No browser testing in this verification
|
|
||||||
|
|
||||||
**Parallelization**:
|
|
||||||
- **Can Run In Parallel**: NO
|
|
||||||
- **Parallel Group**: Wave 2 (sequential after Task 4)
|
|
||||||
- **Blocks**: None (final task)
|
|
||||||
- **Blocked By**: Tasks 1, 2, 3, 4
|
|
||||||
|
|
||||||
**References**:
|
|
||||||
|
|
||||||
**Documentation References**:
|
|
||||||
- `AGENTS.md` (root) — Build/Lint/Test commands: `bun typecheck`, `bun fix`, `bun build`
|
|
||||||
- `apps/desktop/package.json` — Desktop-specific scripts
|
|
||||||
|
|
||||||
**WHY Each Reference Matters**:
|
|
||||||
- Root `AGENTS.md`: Canonical list of verification commands
|
|
||||||
- Desktop `package.json`: Desktop-specific build/typecheck commands
|
|
||||||
|
|
||||||
**Acceptance Criteria**:
|
|
||||||
- [ ] `bun typecheck` (monorepo root) exits with code 0
|
|
||||||
- [ ] `bun build` (monorepo root) exits with code 0
|
|
||||||
- [ ] `bun fix` (monorepo root) produces no changes (all code formatted)
|
|
||||||
- [ ] Build output shows server building before desktop
|
|
||||||
- [ ] Desktop build output includes server bundle (verify in build artifacts)
|
|
||||||
|
|
||||||
**Agent-Executed QA Scenarios:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Scenario: Monorepo typecheck passes
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: All tasks 1-4 completed
|
|
||||||
Steps:
|
|
||||||
1. Run: bun typecheck (from monorepo root)
|
|
||||||
2. Assert: Exit code 0
|
|
||||||
3. Assert: No error output
|
|
||||||
Expected Result: Zero type errors across entire monorepo
|
|
||||||
Evidence: Terminal output captured
|
|
||||||
|
|
||||||
Scenario: Monorepo build succeeds with correct order
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: All tasks 1-4 completed
|
|
||||||
Steps:
|
|
||||||
1. Run: bun build (from monorepo root)
|
|
||||||
2. Assert: Exit code 0
|
|
||||||
3. Assert: Output shows @furtherverse/server build task runs
|
|
||||||
4. Assert: Output shows @furtherverse/desktop build task runs AFTER server
|
|
||||||
Expected Result: Build pipeline executes in correct order
|
|
||||||
Evidence: Terminal output showing task order
|
|
||||||
|
|
||||||
Scenario: Biome finds no issues
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: All tasks 1-4 completed
|
|
||||||
Steps:
|
|
||||||
1. Run: bun fix (from monorepo root)
|
|
||||||
2. Run: git diff
|
|
||||||
3. Assert: No changes (all code already formatted)
|
|
||||||
Expected Result: All code passes Biome rules
|
|
||||||
Evidence: Empty git diff
|
|
||||||
|
|
||||||
Scenario: Desktop build artifacts include server bundle
|
|
||||||
Tool: Bash
|
|
||||||
Preconditions: Build succeeded
|
|
||||||
Steps:
|
|
||||||
1. Search desktop build output directory for server-output/ or index.mjs
|
|
||||||
2. Assert: Server files are present in the desktop bundle
|
|
||||||
Expected Result: Server bundle is included in desktop build output
|
|
||||||
Evidence: File listing of build artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Commit**: NO (verification only — no file changes unless fixing issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commit Strategy
|
|
||||||
|
|
||||||
| After Task(s) | Message | Files | Verification |
|
|
||||||
|---------------|---------|-------|--------------|
|
|
||||||
| 1, 2 | `feat(desktop): add build.copy for server bundle and cross-workspace build dependency` | `electrobun.config.ts`, `turbo.json` | `bun typecheck` |
|
|
||||||
| 3 | `feat(desktop): implement production mode with child process server` | `src/bun/index.ts` | `bun typecheck && bun fix` |
|
|
||||||
| 4 | `docs(desktop): rewrite AGENTS.md to reflect production mode architecture` | `AGENTS.md` | None |
|
|
||||||
| 5 | (no commit — verification only) | — | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
### Verification Commands
|
|
||||||
```bash
|
|
||||||
bun typecheck # Expected: exit 0, no errors
|
|
||||||
bun build # Expected: exit 0, server builds before desktop
|
|
||||||
bun fix # Expected: no changes (already formatted)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Final Checklist
|
|
||||||
- [ ] All "Must Have" features present in `index.ts`
|
|
||||||
- [ ] All "Must NOT Have" exclusions verified absent
|
|
||||||
- [ ] All 3 verification commands pass
|
|
||||||
- [ ] `AGENTS.md` accurately reflects implemented architecture
|
|
||||||
- [ ] Server output is bundled into desktop build via `build.copy`
|
|
||||||
- [ ] Turbo builds server before desktop
|
|
||||||
178
AGENTS.md
178
AGENTS.md
@@ -1,178 +0,0 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
|
||||||
|
|
||||||
Guidelines for AI agents working in this Bun monorepo.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run` / `bun <script>` for scripts. Never use `npm`, `npx`, or `node`.**
|
|
||||||
|
|
||||||
- **Monorepo**: Bun workspaces + Turborepo orchestration
|
|
||||||
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
|
|
||||||
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
|
|
||||||
- **Apps**:
|
|
||||||
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
|
|
||||||
- `apps/desktop` - Electrobun desktop shell, loads server in native window (see `apps/desktop/AGENTS.md`)
|
|
||||||
- **Packages**: `packages/utils`, `packages/tsconfig` (shared configs)
|
|
||||||
|
|
||||||
## Build / Lint / Test Commands
|
|
||||||
|
|
||||||
### Root Commands (via Turbo)
|
|
||||||
```bash
|
|
||||||
bun dev # Start all apps in dev mode
|
|
||||||
bun build # Build all apps
|
|
||||||
bun fix # Lint + format (Biome auto-fix)
|
|
||||||
bun typecheck # TypeScript check across monorepo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server App (`apps/server`)
|
|
||||||
```bash
|
|
||||||
bun dev # Vite dev server (localhost:3000)
|
|
||||||
bun build # Production build -> .output/
|
|
||||||
bun compile # Compile to standalone binary
|
|
||||||
bun fix # Biome auto-fix
|
|
||||||
bun typecheck # TypeScript check
|
|
||||||
|
|
||||||
# Database (Drizzle)
|
|
||||||
bun db:generate # Generate migrations from schema
|
|
||||||
bun db:migrate # Run migrations
|
|
||||||
bun db:push # Push schema (dev only)
|
|
||||||
bun db:studio # Open Drizzle Studio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Desktop App (`apps/desktop`)
|
|
||||||
```bash
|
|
||||||
bun dev # Start Electrobun dev mode (requires server dev running)
|
|
||||||
bun build # Build canary release
|
|
||||||
bun build:stable # Build stable release
|
|
||||||
bun fix # Biome auto-fix
|
|
||||||
bun typecheck # TypeScript check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
No test framework configured yet. When adding tests:
|
|
||||||
```bash
|
|
||||||
bun test path/to/test.ts # Run single test file
|
|
||||||
bun test -t "pattern" # Run tests matching pattern
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style (TypeScript)
|
|
||||||
|
|
||||||
### Formatting (Biome)
|
|
||||||
- **Indent**: 2 spaces | **Line endings**: LF
|
|
||||||
- **Quotes**: Single `'` | **Semicolons**: Omit (ASI)
|
|
||||||
- **Arrow parentheses**: Always `(x) => x`
|
|
||||||
|
|
||||||
### Imports
|
|
||||||
Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { db } from '@/server/db'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
```
|
|
||||||
|
|
||||||
### TypeScript Strictness
|
|
||||||
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true`
|
|
||||||
- Use `@/*` path aliases (maps to `src/*`)
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
| Type | Convention | Example |
|
|
||||||
|------|------------|---------|
|
|
||||||
| Files (utils) | kebab-case | `auth-utils.ts` |
|
|
||||||
| Files (components) | PascalCase | `UserProfile.tsx` |
|
|
||||||
| Components | PascalCase arrow | `const Button = () => {}` |
|
|
||||||
| Functions | camelCase | `getUserById` |
|
|
||||||
| Constants | UPPER_SNAKE | `MAX_RETRIES` |
|
|
||||||
| Types/Interfaces | PascalCase | `UserProfile` |
|
|
||||||
|
|
||||||
### React Patterns
|
|
||||||
- Components: arrow functions (enforced by Biome)
|
|
||||||
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
|
|
||||||
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
|
|
||||||
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Use `try-catch` for async operations; throw descriptive errors
|
|
||||||
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
|
|
||||||
- Never use empty catch blocks
|
|
||||||
|
|
||||||
## Database (Drizzle ORM)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const myTable = pgTable('my_table', {
|
|
||||||
id: uuid().primaryKey().default(sql`uuidv7()`),
|
|
||||||
name: text().notNull(),
|
|
||||||
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
- Use `@t3-oss/env-core` with Zod validation in `src/env.ts`
|
|
||||||
- Server vars: no prefix | Client vars: `VITE_` prefix required
|
|
||||||
- Never commit `.env` files
|
|
||||||
|
|
||||||
## Dependency Management
|
|
||||||
|
|
||||||
- All versions centralized in root `package.json` `catalog` field
|
|
||||||
- Workspace packages use `"catalog:"` — never hardcode versions
|
|
||||||
- Internal packages use `"workspace:*"` references
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- Run `bun fix` before committing
|
|
||||||
- Use `@/*` path aliases (not relative imports)
|
|
||||||
- Include `createdAt`/`updatedAt` on all tables
|
|
||||||
- Use `catalog:` for dependency versions
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
|
|
||||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
|
||||||
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
|
|
||||||
- Commit `.env` files
|
|
||||||
- Use empty catch blocks `catch(e) {}`
|
|
||||||
- Hardcode dependency versions in workspace packages
|
|
||||||
|
|
||||||
## Git Workflow
|
|
||||||
|
|
||||||
1. Make changes following style guide
|
|
||||||
2. `bun fix` - auto-format and lint
|
|
||||||
3. `bun typecheck` - verify types
|
|
||||||
4. `bun dev` - test locally
|
|
||||||
5. Commit with descriptive message
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── apps/
|
|
||||||
│ ├── server/ # TanStack Start fullstack app
|
|
||||||
│ │ ├── src/
|
|
||||||
│ │ │ ├── client/ # ORPC client, Query client
|
|
||||||
│ │ │ ├── components/
|
|
||||||
│ │ │ ├── routes/ # File-based routing
|
|
||||||
│ │ │ └── server/ # API layer + database
|
|
||||||
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
|
|
||||||
│ │ │ └── db/ # Drizzle schema
|
|
||||||
│ │ └── AGENTS.md
|
|
||||||
│ └── desktop/ # Electrobun desktop shell
|
|
||||||
│ ├── src/
|
|
||||||
│ │ └── bun/
|
|
||||||
│ │ └── index.ts # Main process entry
|
|
||||||
│ ├── electrobun.config.ts # Electrobun configuration
|
|
||||||
│ └── AGENTS.md
|
|
||||||
├── packages/
|
|
||||||
│ ├── tsconfig/ # Shared TS configs
|
|
||||||
│ └── utils/ # Shared utilities
|
|
||||||
├── biome.json # Linting/formatting config
|
|
||||||
├── turbo.json # Turbo task orchestration
|
|
||||||
└── package.json # Workspace root + dependency catalog
|
|
||||||
```
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
|
|
||||||
- `apps/desktop/AGENTS.md` - Electrobun desktop development guide
|
|
||||||
3
apps/desktop/.gitignore
vendored
3
apps/desktop/.gitignore
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Electrobun build output
|
|
||||||
build/
|
|
||||||
artifacts/
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# AGENTS.md - Desktop App Guidelines
|
|
||||||
|
|
||||||
Thin Electrobun shell hosting the fullstack server app.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.**
|
|
||||||
|
|
||||||
- **Type**: Electrobun desktop application
|
|
||||||
- **Design**: Server-driven desktop (thin native shell hosting web app)
|
|
||||||
- **Runtime**: Bun (Main process) + CEF (Chromium Embedded Framework)
|
|
||||||
- **Framework**: Electrobun
|
|
||||||
- **Build**: Electrobun CLI + Turborepo
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it merely hosts the `apps/server` TanStack Start application in a native window.
|
|
||||||
- **Dev mode**: Connects to an external Vite dev server at `localhost:3000`. Requires `apps/server` to be running separately.
|
|
||||||
- **Prod mode**: Spawns an embedded TanStack Start server (Nitro) as a child process and loads the dynamically assigned local URL.
|
|
||||||
- **Config**: `electrobun.config.ts` manages app metadata (identifier, name), build entries, and asset bundling.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
bun dev # Start Electrobun dev mode (requires server dev running)
|
|
||||||
|
|
||||||
# Build
|
|
||||||
bun build # Build stable release (all platforms)
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
bun fix # Biome auto-fix
|
|
||||||
bun typecheck # TypeScript check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── src/
|
|
||||||
│ └── bun/
|
|
||||||
│ └── index.ts # Main process entry (Window management + server lifecycle)
|
|
||||||
├── electrobun.config.ts # App metadata and build/copy configuration
|
|
||||||
├── package.json # Scripts and dependencies
|
|
||||||
├── turbo.json # Build pipeline dependencies (depends on server build)
|
|
||||||
└── AGENTS.md # Desktop guidelines (this file)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
1. **Start server**: In `apps/server`, run `bun dev`.
|
|
||||||
2. **Start desktop**: In `apps/desktop`, run `bun dev`.
|
|
||||||
3. **Connection**: The desktop app polls `localhost:3000` until responsive, then opens the native window.
|
|
||||||
|
|
||||||
## Production Architecture
|
|
||||||
|
|
||||||
### Build Pipeline
|
|
||||||
The desktop build is orchestrated by Turbo. It depends on the server's production build:
|
|
||||||
- `turbo.json`: `@furtherverse/desktop#build` depends on `@furtherverse/server#build`.
|
|
||||||
- `electrobun.config.ts`: Copies `../server/.output` to `server-output` folder within the app bundle.
|
|
||||||
|
|
||||||
### Server Lifecycle
|
|
||||||
In production, the main process manages the embedded server:
|
|
||||||
- **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`.
|
|
||||||
- **Port Allocation**: A free port is pre-allocated via `node:net` (`createServer` on `127.0.0.1:0`), then passed to the server as the `PORT` environment variable.
|
|
||||||
- **Readiness Check**: The main process polls the server URL with `fetch` until it responds, rather than parsing stdout.
|
|
||||||
- **Retry**: If the server fails to become ready (timeout or early exit), the process is killed and a new attempt is made with a fresh port (up to 3 retries).
|
|
||||||
- **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error.
|
|
||||||
|
|
||||||
## Environment Detection
|
|
||||||
|
|
||||||
The application determines its environment via the `ELECTROBUN_BUILD_ENV` variable, automatically set by the Electrobun CLI:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const isDev = () => {
|
|
||||||
const env = process.env.ELECTROBUN_BUILD_ENV
|
|
||||||
return !env || env === 'dev'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **dev**: Development mode (connects to external host).
|
|
||||||
- **canary / stable**: Production mode (starts embedded server).
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
- `ELECTROBUN_BUILD_ENV`: Auto-set by CLI. Determines whether to use local dev server or embedded server.
|
|
||||||
- `DATABASE_URL`: Required by the server process. Must be passed through from the parent environment to the spawned child process.
|
|
||||||
|
|
||||||
## Electrobun Patterns
|
|
||||||
|
|
||||||
### BrowserWindow Configuration
|
|
||||||
The main window uses the CEF renderer for consistency across platforms.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
new BrowserWindow({
|
|
||||||
title: 'Furtherverse',
|
|
||||||
url: serverUrl,
|
|
||||||
frame: {
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
width: 1200,
|
|
||||||
height: 800,
|
|
||||||
},
|
|
||||||
renderer: 'cef',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path Aliases
|
|
||||||
The main process uses `electrobun/bun` for native APIs and `PATHS` for locating bundled assets.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
|
||||||
|
|
||||||
// Locate the embedded server bundle
|
|
||||||
const serverEntryPath = join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- Use arrow functions for all components and utility functions.
|
|
||||||
- Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo).
|
|
||||||
- Pre-allocate a free port and pass it via `PORT` env var instead of parsing stdout.
|
|
||||||
- Handle server process termination to avoid orphan processes.
|
|
||||||
- Use `catalog:` for all dependency versions in `package.json`.
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- Use `npm`, `npx`, `node`, `yarn`, or `pnpm`. Always use `bun`.
|
|
||||||
- Hardcode `localhost:3000` for production builds.
|
|
||||||
- Include UI components or business logic in the desktop app (keep it thin).
|
|
||||||
- Use `as any` or `@ts-ignore`.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { ElectrobunConfig } from 'electrobun'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
app: {
|
|
||||||
name: 'Desktop',
|
|
||||||
identifier: 'com.furtherverse.desktop',
|
|
||||||
version: '1.0.0',
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
bun: {
|
|
||||||
entrypoint: 'src/bun/index.ts',
|
|
||||||
},
|
|
||||||
copy: {
|
|
||||||
'../server/.output': 'server-output',
|
|
||||||
},
|
|
||||||
targets: 'win-x64',
|
|
||||||
linux: {
|
|
||||||
bundleCEF: true,
|
|
||||||
},
|
|
||||||
mac: {
|
|
||||||
bundleCEF: false,
|
|
||||||
},
|
|
||||||
win: {
|
|
||||||
bundleCEF: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies ElectrobunConfig
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@furtherverse/desktop",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "electrobun build --env=stable",
|
|
||||||
"dev": "electrobun build && electrobun dev",
|
|
||||||
"fix": "biome check --write",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"electrobun": "catalog:"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@furtherverse/tsconfig": "workspace:*",
|
|
||||||
"@types/bun": "catalog:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { createServer } from 'node:net'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
|
||||||
|
|
||||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
|
||||||
const SERVER_READY_TIMEOUT_MS = 5_000
|
|
||||||
const MAX_SPAWN_RETRIES = 3
|
|
||||||
|
|
||||||
const isDev = (): boolean => {
|
|
||||||
const env = process.env.ELECTROBUN_BUILD_ENV
|
|
||||||
return !env || env === 'dev'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getServerEntryPath = (): string => {
|
|
||||||
return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFreePort = (): Promise<number> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const srv = createServer()
|
|
||||||
srv.unref()
|
|
||||||
srv.once('error', reject)
|
|
||||||
srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
|
|
||||||
const addr = srv.address()
|
|
||||||
if (addr && typeof addr === 'object') {
|
|
||||||
const port = addr.port
|
|
||||||
srv.close((err) => (err ? reject(err) : resolve(port)))
|
|
||||||
} else {
|
|
||||||
srv.close(() => reject(new Error('Unexpected address() result')))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitForServer = async (
|
|
||||||
url: string,
|
|
||||||
timeoutMs = SERVER_READY_TIMEOUT_MS,
|
|
||||||
): Promise<boolean> => {
|
|
||||||
const start = Date.now()
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { method: 'HEAD' })
|
|
||||||
if (response.ok) return true
|
|
||||||
} catch (_) {
|
|
||||||
// Server not up yet, retry after sleep
|
|
||||||
}
|
|
||||||
await Bun.sleep(100)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const spawnServer = async (): Promise<{
|
|
||||||
process: ReturnType<typeof Bun.spawn>
|
|
||||||
url: string
|
|
||||||
}> => {
|
|
||||||
const serverEntryPath = getServerEntryPath()
|
|
||||||
const serverDir = dirname(serverEntryPath)
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_SPAWN_RETRIES; attempt++) {
|
|
||||||
const port = await getFreePort()
|
|
||||||
const url = `http://127.0.0.1:${port}`
|
|
||||||
|
|
||||||
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
|
|
||||||
cwd: serverDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PORT: String(port),
|
|
||||||
HOST: '127.0.0.1',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'inherit', 'inherit'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const ready = await Promise.race([
|
|
||||||
waitForServer(url),
|
|
||||||
serverProc.exited.then((code) => {
|
|
||||||
throw new Error(`Server exited with code ${code} before becoming ready`)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (ready) {
|
|
||||||
return { process: serverProc, url }
|
|
||||||
}
|
|
||||||
|
|
||||||
serverProc.kill()
|
|
||||||
await serverProc.exited
|
|
||||||
console.warn(
|
|
||||||
`Server failed to become ready on port ${port} (attempt ${attempt}/${MAX_SPAWN_RETRIES})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Server failed to start after ${MAX_SPAWN_RETRIES} attempts`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
console.log('Starting Furtherverse Desktop...')
|
|
||||||
|
|
||||||
let serverUrl: string
|
|
||||||
let serverProcess: ReturnType<typeof Bun.spawn> | null = null
|
|
||||||
|
|
||||||
if (isDev()) {
|
|
||||||
console.log('Dev mode: waiting for external server at', DEV_SERVER_URL)
|
|
||||||
const ready = await waitForServer(DEV_SERVER_URL)
|
|
||||||
if (!ready) {
|
|
||||||
console.error(
|
|
||||||
'Dev server not responding. Make sure to run: bun dev in apps/server',
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
console.log('Dev server ready!')
|
|
||||||
serverUrl = DEV_SERVER_URL
|
|
||||||
} else {
|
|
||||||
console.log('Production mode: starting embedded server...')
|
|
||||||
try {
|
|
||||||
const server = await spawnServer()
|
|
||||||
serverProcess = server.process
|
|
||||||
serverUrl = server.url
|
|
||||||
console.log('Server ready at', serverUrl)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to start embedded server:', err)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new BrowserWindow({
|
|
||||||
title: 'Furtherverse',
|
|
||||||
url: serverUrl,
|
|
||||||
frame: {
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
width: 1200,
|
|
||||||
height: 800,
|
|
||||||
},
|
|
||||||
renderer: 'cef',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (serverProcess) {
|
|
||||||
const cleanup = () => {
|
|
||||||
if (serverProcess) {
|
|
||||||
serverProcess.kill()
|
|
||||||
serverProcess = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('exit', cleanup)
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
cleanup()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
cleanup()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
serverProcess.exited.then((code) => {
|
|
||||||
if (serverProcess) {
|
|
||||||
console.error(`Server exited unexpectedly with code ${code}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error('Failed to start:', error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@furtherverse/tsconfig/bun.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../../node_modules/turbo/schema.json",
|
|
||||||
"extends": ["//"],
|
|
||||||
"tasks": {
|
|
||||||
"build": {
|
|
||||||
"dependsOn": ["@furtherverse/server#build"],
|
|
||||||
"outputs": ["build/**", "artifacts/**"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
DATABASE_URL=./data/app.db
|
||||||
|
|||||||
5
apps/server/.gitignore
vendored
Normal file
5
apps/server/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# SQLite database files
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -1,205 +1,420 @@
|
|||||||
# AGENTS.md - Server App Guidelines
|
# AGENTS.md - AI Coding Agent Guidelines
|
||||||
|
|
||||||
TanStack Start fullstack web app with ORPC (contract-first RPC).
|
本文档为 AI 编程助手提供此 TanStack Start 全栈项目的开发规范和指南。
|
||||||
|
|
||||||
## Tech Stack
|
## 项目概览
|
||||||
|
|
||||||
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.**
|
- **框架**: TanStack Start (React SSR 框架,文件路由)
|
||||||
|
- **运行时**: Bun
|
||||||
|
- **语言**: TypeScript (strict mode, ESNext)
|
||||||
|
- **样式**: Tailwind CSS v4
|
||||||
|
- **数据库**: SQLite + Drizzle ORM
|
||||||
|
- **状态管理**: TanStack Query
|
||||||
|
- **路由**: TanStack Router (文件路由)
|
||||||
|
- **RPC**: ORPC (类型安全 RPC,契约优先)
|
||||||
|
- **构建工具**: Vite + Turbo
|
||||||
|
- **代码质量**: Biome (格式化 + Lint)
|
||||||
|
|
||||||
- **Framework**: TanStack Start (React 19 SSR, file-based routing)
|
## 依赖管理
|
||||||
- **Runtime**: Bun — **NOT Node.js**
|
|
||||||
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
|
|
||||||
- **Language**: TypeScript (strict mode)
|
|
||||||
- **Styling**: Tailwind CSS v4
|
|
||||||
- **Database**: PostgreSQL + Drizzle ORM
|
|
||||||
- **State**: TanStack Query v5
|
|
||||||
- **RPC**: ORPC (contract-first, type-safe)
|
|
||||||
- **Build**: Vite + Nitro
|
|
||||||
|
|
||||||
## Commands
|
项目使用 **Bun Catalog** 统一管理依赖版本。
|
||||||
|
|
||||||
|
> **详细流程**: 加载 skill `bun-catalog-package` 获取完整指南。
|
||||||
|
|
||||||
|
**快速参考**:
|
||||||
```bash
|
```bash
|
||||||
# Development
|
bun info <pkg> version # 查询最新版本
|
||||||
bun dev # Vite dev server (localhost:3000)
|
bun add <pkg>@catalog: # 在子包中安装
|
||||||
bun db:studio # Drizzle Studio GUI
|
|
||||||
|
|
||||||
# Build
|
|
||||||
bun build # Production build → .output/
|
|
||||||
bun compile # Compile to standalone binary
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
bun fix # Biome auto-fix
|
|
||||||
bun typecheck # TypeScript check
|
|
||||||
|
|
||||||
# Database
|
|
||||||
bun db:generate # Generate migrations from schema
|
|
||||||
bun db:migrate # Run migrations
|
|
||||||
bun db:push # Push schema directly (dev only)
|
|
||||||
|
|
||||||
# Testing (not yet configured)
|
|
||||||
bun test path/to/test.ts # Run single test
|
|
||||||
bun test -t "pattern" # Run tests matching pattern
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory Structure
|
## 构建、Lint 和测试命令
|
||||||
|
|
||||||
```
|
### 开发
|
||||||
src/
|
```bash
|
||||||
├── client/ # Client-side code
|
bun dev # 启动 Vite 开发服务器
|
||||||
│ ├── orpc.client.ts # ORPC isomorphic client
|
bun db:studio # 打开 Drizzle Studio 数据库管理界面
|
||||||
│ └── query-client.ts # TanStack Query client
|
|
||||||
├── components/ # React components
|
|
||||||
├── routes/ # TanStack Router file routes
|
|
||||||
│ ├── __root.tsx # Root layout
|
|
||||||
│ ├── index.tsx # Home page
|
|
||||||
│ └── api/
|
|
||||||
│ └── rpc.$.ts # ORPC HTTP endpoint
|
|
||||||
├── server/ # Server-side code
|
|
||||||
│ ├── api/ # ORPC layer
|
|
||||||
│ │ ├── contracts/ # Input/output schemas (Zod)
|
|
||||||
│ │ ├── middlewares/ # Middleware (db provider, auth)
|
|
||||||
│ │ ├── routers/ # Handler implementations
|
|
||||||
│ │ ├── context.ts # Request context
|
|
||||||
│ │ ├── server.ts # ORPC server instance
|
|
||||||
│ │ └── types.ts # Type exports
|
|
||||||
│ └── db/
|
|
||||||
│ ├── schema/ # Drizzle table definitions
|
|
||||||
│ └── index.ts # Database instance
|
|
||||||
├── env.ts # Environment variable validation
|
|
||||||
├── router.tsx # Router configuration
|
|
||||||
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
|
|
||||||
└── styles.css # Tailwind entry
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ORPC Pattern
|
### 构建
|
||||||
|
```bash
|
||||||
|
bun build # 构建 Vite 应用 (输出到 .output/)
|
||||||
|
bun compile # 编译为独立可执行文件 (使用 build.ts)
|
||||||
|
```
|
||||||
|
|
||||||
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`)
|
### 代码质量
|
||||||
|
```bash
|
||||||
|
bun typecheck # 运行 TypeScript 类型检查
|
||||||
|
bun fix # 运行 Biome 自动修复格式和 Lint 问题
|
||||||
|
biome check . # 检查但不自动修复
|
||||||
|
biome format --write . # 仅格式化代码
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
```bash
|
||||||
|
bun db:generate # 从 schema 生成迁移文件
|
||||||
|
bun db:migrate # 执行数据库迁移
|
||||||
|
bun db:push # 直接推送 schema 变更 (仅开发环境)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
**注意**: 当前未配置测试框架。添加测试时:
|
||||||
|
- 使用 Vitest 或 Bun 内置测试运行器
|
||||||
|
- 运行单个测试文件: `bun test path/to/test.ts`
|
||||||
|
- 运行特定测试: `bun test -t "测试名称模式"`
|
||||||
|
|
||||||
|
## 代码风格指南
|
||||||
|
|
||||||
|
### 格式化 (Biome)
|
||||||
|
|
||||||
|
**缩进**: 2 空格 (不使用 tab)
|
||||||
|
**换行符**: LF (Unix 风格)
|
||||||
|
**引号**: 单引号 `'string'`
|
||||||
|
**分号**: 按需 (ASI - 自动分号插入)
|
||||||
|
**箭头函数括号**: 始终使用 `(x) => x`
|
||||||
|
|
||||||
|
示例:
|
||||||
```typescript
|
```typescript
|
||||||
import { oc } from '@orpc/contract'
|
const myFunc = (value: string) => {
|
||||||
import { createSelectSchema } from 'drizzle-zod'
|
return value.toUpperCase()
|
||||||
import { z } from 'zod'
|
}
|
||||||
import { featureTable } from '@/server/db/schema'
|
|
||||||
|
|
||||||
const selectSchema = createSelectSchema(featureTable)
|
|
||||||
|
|
||||||
export const list = oc.input(z.void()).output(z.array(selectSchema))
|
|
||||||
export const create = oc.input(insertSchema).output(selectSchema)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Implement Router (`src/server/api/routers/feature.router.ts`)
|
### 导入组织
|
||||||
```typescript
|
|
||||||
import { ORPCError } from '@orpc/server'
|
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const list = os.feature.list.use(db).handler(async ({ context }) => {
|
Biome 自动组织导入。顺序:
|
||||||
return await context.db.query.featureTable.findMany()
|
1. 外部依赖
|
||||||
})
|
2. 内部导入 (使用 `@/*` 别名)
|
||||||
```
|
3. 类型导入 (仅导入类型时使用 `type` 关键字)
|
||||||
|
|
||||||
### 3. Register in Index Files
|
|
||||||
```typescript
|
|
||||||
// src/server/api/contracts/index.ts
|
|
||||||
import * as feature from './feature.contract'
|
|
||||||
export const contract = { feature }
|
|
||||||
|
|
||||||
// src/server/api/routers/index.ts
|
|
||||||
import * as feature from './feature.router'
|
|
||||||
export const router = os.router({ feature })
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Use in Components
|
|
||||||
```typescript
|
|
||||||
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
|
|
||||||
import { orpc } from '@/client/orpc.client'
|
|
||||||
|
|
||||||
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
|
|
||||||
const mutation = useMutation(orpc.feature.create.mutationOptions())
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema (Drizzle)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
|
||||||
import { sql } from 'drizzle-orm'
|
|
||||||
|
|
||||||
export const myTable = pgTable('my_table', {
|
|
||||||
id: uuid().primaryKey().default(sql`uuidv7()`),
|
|
||||||
name: text().notNull(),
|
|
||||||
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### Formatting (Biome)
|
|
||||||
- **Indent**: 2 spaces
|
|
||||||
- **Quotes**: Single `'`
|
|
||||||
- **Semicolons**: Omit (ASI)
|
|
||||||
- **Arrow parens**: Always `(x) => x`
|
|
||||||
|
|
||||||
### Imports
|
|
||||||
Biome auto-organizes:
|
|
||||||
1. External packages
|
|
||||||
2. Internal `@/*` aliases
|
|
||||||
3. Type imports (`import type { ... }`)
|
|
||||||
|
|
||||||
|
示例:
|
||||||
```typescript
|
```typescript
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { oc } from '@orpc/contract'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/db'
|
||||||
|
import { todoTable } from '@/db/schema'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
```
|
```
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
|
**严格模式**: 启用了额外的严格检查
|
||||||
- `strict: true`
|
- `strict: true`
|
||||||
- `noUncheckedIndexedAccess: true` - array access returns `T | undefined`
|
- `noUncheckedIndexedAccess: true` - 数组/对象索引返回 `T | undefined`
|
||||||
- Use `@/*` path aliases (maps to `src/*`)
|
- `noImplicitOverride: true`
|
||||||
|
- `noFallthroughCasesInSwitch: true`
|
||||||
|
|
||||||
### Naming
|
**模块解析**: `bundler` 模式 + `verbatimModuleSyntax`
|
||||||
| Type | Convention | Example |
|
- 导入时始终使用 `.ts`/`.tsx` 扩展名
|
||||||
|------|------------|---------|
|
- 使用 `@/*` 路径别名指向 `src/*`
|
||||||
| Files (utils) | kebab-case | `auth-utils.ts` |
|
|
||||||
| Files (components) | PascalCase | `UserProfile.tsx` |
|
|
||||||
| Components | PascalCase arrow | `const Button = () => {}` |
|
|
||||||
| Functions | camelCase | `getUserById` |
|
|
||||||
| Types | PascalCase | `UserProfile` |
|
|
||||||
|
|
||||||
### React
|
**类型注解**:
|
||||||
- Use arrow functions for components (Biome enforced)
|
- 公共 API 的函数参数和返回类型必须注解
|
||||||
- Use `useSuspenseQuery` for guaranteed data
|
- 优先使用显式类型而非 `any`
|
||||||
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
|
- 对象形状用 `type`,可扩展契约用 `interface`
|
||||||
|
- 不可变 props 使用 `Readonly<T>`
|
||||||
|
|
||||||
## Environment Variables
|
### 命名规范
|
||||||
|
|
||||||
|
- **文件**: 工具函数用 kebab-case,组件用 PascalCase
|
||||||
|
- `utils.ts`, `todo.tsx`, `NotFound.tsx`
|
||||||
|
- **路由**: 遵循 TanStack Router 约定
|
||||||
|
- `routes/index.tsx` → `/`
|
||||||
|
- `routes/__root.tsx` → 根布局
|
||||||
|
- **组件**: PascalCase 箭头函数 (Biome 规则 `useArrowFunction` 强制)
|
||||||
|
- **函数**: camelCase
|
||||||
|
- **常量**: 真常量用 UPPER_SNAKE_CASE,配置对象用 camelCase
|
||||||
|
- **类型/接口**: PascalCase
|
||||||
|
|
||||||
|
### React 模式
|
||||||
|
|
||||||
|
**组件**: 使用箭头函数
|
||||||
```typescript
|
```typescript
|
||||||
// src/env.ts - using @t3-oss/env-core
|
const MyComponent = ({ title }: { title: string }) => {
|
||||||
import { createEnv } from '@t3-oss/env-core'
|
return <div>{title}</div>
|
||||||
import { z } from 'zod'
|
}
|
||||||
|
```
|
||||||
|
|
||||||
export const env = createEnv({
|
**路由**: 使用 `createFileRoute` 定义路由
|
||||||
server: {
|
```typescript
|
||||||
DATABASE_URL: z.string().url(),
|
export const Route = createFileRoute('/')({
|
||||||
},
|
component: Home,
|
||||||
clientPrefix: 'VITE_',
|
|
||||||
client: {
|
|
||||||
VITE_API_URL: z.string().optional(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Critical Rules
|
**数据获取**: 使用 TanStack Query hooks
|
||||||
|
- `useSuspenseQuery` - 保证有数据
|
||||||
|
- `useQuery` - 数据可能为空
|
||||||
|
|
||||||
**DO:**
|
**Props**: 禁止直接修改 props (Biome 规则 `noReactPropAssignments`)
|
||||||
- Run `bun fix` before committing
|
|
||||||
- Use `@/*` path aliases
|
|
||||||
- Include `createdAt`/`updatedAt` on all tables
|
|
||||||
- Use `ORPCError` with proper codes
|
|
||||||
|
|
||||||
**DON'T:**
|
### 数据库 Schema (Drizzle)
|
||||||
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
|
|
||||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
- 在 `src/server/db/schema/*.ts` 定义 schema
|
||||||
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
|
- 从 `src/server/db/schema/index.ts` 导出
|
||||||
- Commit `.env` files
|
- 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型
|
||||||
- Use empty catch blocks
|
- 主键使用 `uuidv7()` (TEXT 存储)
|
||||||
|
- 时间戳使用 `integer({ mode: 'timestamp_ms' })` (Unix 毫秒时间戳)
|
||||||
|
- 始终包含 `createdAt` 和 `updatedAt` 时间戳
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```typescript
|
||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||||
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
|
||||||
|
export const myTable = sqliteTable('my_table', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => uuidv7()),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date()),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
- 使用 `@t3-oss/env-core` 进行类型安全的环境变量验证
|
||||||
|
- 在 `src/env.ts` 定义 schema
|
||||||
|
- 服务端变量: 无前缀
|
||||||
|
- 客户端变量: 必须有 `VITE_` 前缀
|
||||||
|
- 使用 Zod schema 验证
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
- 异步操作使用 try-catch
|
||||||
|
- 抛出带有描述性消息的错误
|
||||||
|
- 用户界面错误优先使用 Result 类型或错误边界
|
||||||
|
- 适当记录错误 (避免记录敏感数据)
|
||||||
|
|
||||||
|
### 样式 (Tailwind CSS)
|
||||||
|
|
||||||
|
- 使用 Tailwind v4 工具类
|
||||||
|
- 通过 `@/styles.css?url` 导入样式
|
||||||
|
- 优先使用组合而非自定义 CSS
|
||||||
|
- 响应式修饰符: `sm:`, `md:`, `lg:`
|
||||||
|
- UI 文本适当使用中文
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # 可复用 React 组件
|
||||||
|
├── db/
|
||||||
|
│ ├── schema/ # Drizzle schema 定义
|
||||||
|
│ └── index.ts # 数据库实例
|
||||||
|
├── integrations/ # 第三方集成 (TanStack Query/Router)
|
||||||
|
├── lib/ # 工具函数
|
||||||
|
├── orpc/ # ORPC (RPC 层)
|
||||||
|
│ ├── contracts/ # 契约定义 (input/output schemas)
|
||||||
|
│ ├── handlers/ # 服务端过程实现
|
||||||
|
│ ├── middlewares/ # 中间件 (如 DB provider)
|
||||||
|
│ ├── contract.ts # 契约聚合
|
||||||
|
│ ├── router.ts # 路由组合
|
||||||
|
│ ├── server.ts # 服务端实例
|
||||||
|
│ └── client.ts # 同构客户端
|
||||||
|
├── routes/ # TanStack Router 文件路由
|
||||||
|
│ ├── __root.tsx # 根布局
|
||||||
|
│ ├── index.tsx # 首页
|
||||||
|
│ └── api/rpc.$.ts # ORPC HTTP 端点
|
||||||
|
├── env.ts # 环境变量验证
|
||||||
|
└── router.tsx # 路由配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重要提示
|
||||||
|
|
||||||
|
- **禁止** 编辑 `src/routeTree.gen.ts` - 自动生成
|
||||||
|
- **禁止** 提交 `.env` 文件 - 使用 `.env.example` 作为模板
|
||||||
|
- **必须** 在提交前运行 `bun fix`
|
||||||
|
- **必须** 使用 `@/*` 路径别名而非相对导入
|
||||||
|
- **必须** 利用 React Compiler (babel-plugin-react-compiler) - 避免手动 memoization
|
||||||
|
|
||||||
|
## Git 工作流
|
||||||
|
|
||||||
|
1. 按照上述风格指南进行修改
|
||||||
|
2. 运行 `bun fix` 自动格式化和 lint
|
||||||
|
3. 运行 `bun typecheck` 确保类型安全
|
||||||
|
4. 使用 `bun dev` 本地测试变更
|
||||||
|
5. 使用清晰的描述性消息提交
|
||||||
|
|
||||||
|
## 常见模式
|
||||||
|
|
||||||
|
### 创建 ORPC 过程
|
||||||
|
|
||||||
|
**步骤 1: 定义契约** (`src/orpc/contracts/my-feature.ts`)
|
||||||
|
```typescript
|
||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const myContract = {
|
||||||
|
get: oc.input(z.object({ id: z.uuid() })).output(mySchema),
|
||||||
|
create: oc.input(createSchema).output(mySchema),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 2: 实现处理器** (`src/orpc/handlers/my-feature.ts`)
|
||||||
|
```typescript
|
||||||
|
import { os } from '@/orpc/server'
|
||||||
|
import { dbProvider } from '@/orpc/middlewares'
|
||||||
|
|
||||||
|
export const get = os.myFeature.get
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context, input }) => {
|
||||||
|
return await context.db.query.myTable.findFirst(...)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 3: 注册到契约和路由**
|
||||||
|
```typescript
|
||||||
|
// src/orpc/contract.ts
|
||||||
|
export const contract = { myFeature: myContract }
|
||||||
|
|
||||||
|
// src/orpc/router.ts
|
||||||
|
import * as myFeature from './handlers/my-feature'
|
||||||
|
export const router = os.router({ myFeature })
|
||||||
|
```
|
||||||
|
|
||||||
|
**步骤 4: 在组件中使用**
|
||||||
|
```typescript
|
||||||
|
import { orpc } from '@/orpc'
|
||||||
|
const query = useSuspenseQuery(orpc.myFeature.get.queryOptions({ id }))
|
||||||
|
const mutation = useMutation(orpc.myFeature.create.mutationOptions())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知问题与解决方案
|
||||||
|
|
||||||
|
### 构建问题
|
||||||
|
|
||||||
|
**已解决**: Vite 8.0.0-beta.10 已修复与 Nitro 插件的兼容性问题
|
||||||
|
- **当前版本**: Vite 8.0.0-beta.10 + nitro-nightly@3.0.1-20260125
|
||||||
|
- **状态**: 构建稳定,开发和生产环境均正常工作
|
||||||
|
|
||||||
|
### 依赖选择经验
|
||||||
|
|
||||||
|
**ohash vs crypto.createHash**
|
||||||
|
|
||||||
|
在实现硬件指纹功能时,曾误判 `ohash` 不适合用于硬件指纹识别。经深入研究发现:
|
||||||
|
|
||||||
|
**事实**:
|
||||||
|
- `ohash` 内部使用**完整的 SHA-256** 算法(256 位)
|
||||||
|
- 输出 43 字符 Base64URL 编码(等价于 64 字符 Hex)
|
||||||
|
- 碰撞概率与 `crypto.createHash('sha256')` **完全相同**(2^128)
|
||||||
|
- 自动处理对象序列化,代码更简洁
|
||||||
|
|
||||||
|
**对比**:
|
||||||
|
```typescript
|
||||||
|
// ohash - 推荐用于对象哈希
|
||||||
|
import { hash } from 'ohash'
|
||||||
|
const fingerprint = hash(systemInfo) // 一行搞定
|
||||||
|
|
||||||
|
// crypto - 需要手动序列化
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
const fingerprint = createHash('sha256')
|
||||||
|
.update(JSON.stringify(systemInfo))
|
||||||
|
.digest('base64url')
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**:
|
||||||
|
- ✅ `ohash` 完全适合硬件指纹场景(数据来自系统 API,非用户输入)
|
||||||
|
- ✅ 两者安全性等价,选择取决于代码风格偏好
|
||||||
|
- ⚠️ ohash 文档警告的"序列化安全性"仅针对**用户输入**场景
|
||||||
|
|
||||||
|
**经验教训**:
|
||||||
|
- 不要仅凭名称("短哈希")判断库的实现
|
||||||
|
- 深入研究文档和源码再做技术决策
|
||||||
|
- 区分"用户输入场景"和"系统数据场景"的安全要求
|
||||||
|
|
||||||
|
### 缓存库选择:@isaacs/ttlcache
|
||||||
|
|
||||||
|
**决策时间**: 2026-01-26
|
||||||
|
|
||||||
|
**背景**:
|
||||||
|
硬件指纹功能最初使用手动实现的 TTL 缓存(module-level 变量 + 手动过期检查)。为提高代码可维护性,迁移到专业缓存库。
|
||||||
|
|
||||||
|
**选型**:
|
||||||
|
- **选择**: `@isaacs/ttlcache` v2.1.4
|
||||||
|
- **理由**:
|
||||||
|
- 专为 TTL 场景优化,无需 LRU 追踪开销
|
||||||
|
- 零依赖,6M+ 周下载量
|
||||||
|
- 内置 TypeScript 类型
|
||||||
|
- 自动过期管理,无需手动定时器
|
||||||
|
- API 简洁: `new TTLCache({ ttl, max })`
|
||||||
|
|
||||||
|
**实现细节**:
|
||||||
|
- 保留 `inFlight` Promise 模式用于并发请求去重(TTLCache 不提供此功能)
|
||||||
|
- 使用单一缓存键 `'fingerprint'`(单服务器场景,opts 不影响输出)
|
||||||
|
- 默认 TTL: 10 分钟(可通过 `cacheTtlMs` 参数覆盖)
|
||||||
|
|
||||||
|
**对比手动实现**:
|
||||||
|
- ✅ 更少自定义代码
|
||||||
|
- ✅ 更清晰的 TTL 语义
|
||||||
|
- ✅ 经过充分测试的库
|
||||||
|
- ⚠️ 仍需手动处理并发去重
|
||||||
|
|
||||||
|
**经验教训**:
|
||||||
|
- 专业库不一定解决所有问题(如并发去重)
|
||||||
|
- 对于简单场景,手动实现 vs 库的选择主要取决于可维护性而非功能
|
||||||
|
|
||||||
|
### SQLite 数据库使用说明
|
||||||
|
|
||||||
|
**技术栈**:
|
||||||
|
- **驱动**: `better-sqlite3` v11.8.1 (原生模块,跨平台)
|
||||||
|
- **类型定义**: `@types/better-sqlite3` v7.6.12
|
||||||
|
- **数据库文件**: `./data/app.db`
|
||||||
|
|
||||||
|
**数据类型策略**:
|
||||||
|
- **主键**: TEXT 存储 UUIDv7 (36 字符字符串,全局唯一)
|
||||||
|
- **时间戳**: INTEGER 存储 Unix 毫秒时间戳 (`integer({ mode: 'timestamp_ms' })`)
|
||||||
|
- Drizzle 自动转换 `Date` ↔ `number`
|
||||||
|
- **布尔值**: INTEGER 存储 0/1 (`integer({ mode: 'boolean' })`)
|
||||||
|
- Drizzle 自动转换 `boolean` ↔ `0/1`
|
||||||
|
|
||||||
|
**数据库连接**:
|
||||||
|
```typescript
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
|
||||||
|
export const createDB = () => {
|
||||||
|
const dbPath = env.DATABASE_URL
|
||||||
|
if (dbPath !== ':memory:') {
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true })
|
||||||
|
}
|
||||||
|
const sqlite = new Database(dbPath)
|
||||||
|
return drizzle(sqlite, { schema })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项**:
|
||||||
|
- SQLite 是文件数据库,适合嵌入环境和单实例应用
|
||||||
|
- 不支持多进程并发写入
|
||||||
|
- 数据库文件和 WAL 文件 (`.db-shm`, `.db-wal`) 已添加到 `.gitignore`
|
||||||
|
- `better-sqlite3` 是原生模块,构建时需匹配生产环境 OS 和架构
|
||||||
|
|
||||||
|
### AGENTS.md 文档维护原则
|
||||||
|
|
||||||
|
**核心原则**:AGENTS.md 只记录当前项目状态,不记录重构历史
|
||||||
|
|
||||||
|
1. **当前状态优先**:只描述项目当前使用的技术栈、架构和最佳实践
|
||||||
|
2. **无需向后兼容**:不保留旧技术栈的文档,重构后直接更新为新状态
|
||||||
|
3. **避免历史记录**:不记录"从 X 迁移到 Y"的过程,只记录"当前使用 Y"
|
||||||
|
4. **保持简洁**:删除过时信息,避免上下文过长影响 AI 理解
|
||||||
|
5. **及时同步**:架构变更后立即更新文档,确保文档与代码一致
|
||||||
|
|
||||||
|
**何时更新 AGENTS.md**:
|
||||||
|
- 更换技术栈(如数据库、框架)
|
||||||
|
- 修改项目架构或目录结构
|
||||||
|
- 添加/移除重要依赖
|
||||||
|
- 发现重要的最佳实践或经验教训
|
||||||
|
|
||||||
|
**何时不更新 AGENTS.md**:
|
||||||
|
- 日常功能开发
|
||||||
|
- Bug 修复
|
||||||
|
- 代码重构(不涉及架构变更)
|
||||||
|
- 临时实验或 POC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2026-01-26
|
||||||
|
**项目版本**: 基于 package.json 依赖版本
|
||||||
|
|||||||
@@ -1,111 +1,289 @@
|
|||||||
import { rm } from 'node:fs/promises'
|
import { Schema } from '@effect/schema'
|
||||||
import path from 'node:path'
|
import { $ } from 'bun'
|
||||||
import process from 'node:process'
|
import { Console, Context, Data, Effect, Layer } from 'effect'
|
||||||
|
|
||||||
const ALL_TARGETS = [
|
// ============================================================================
|
||||||
|
// Domain Models & Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const BunTargetSchema = Schema.Literal(
|
||||||
'bun-windows-x64',
|
'bun-windows-x64',
|
||||||
'bun-darwin-arm64',
|
'bun-darwin-arm64',
|
||||||
'bun-darwin-x64',
|
'bun-darwin-x64',
|
||||||
'bun-linux-x64',
|
'bun-linux-x64',
|
||||||
'bun-linux-arm64',
|
'bun-linux-arm64',
|
||||||
] as const
|
)
|
||||||
|
|
||||||
type BunTarget = (typeof ALL_TARGETS)[number]
|
/**
|
||||||
|
* 将 bun target 转换为文件后缀 (去掉 'bun-' 前缀)
|
||||||
const ENTRYPOINT = '.output/server/index.mjs'
|
*/
|
||||||
const OUTDIR = 'out'
|
const getTargetSuffix = (target: BunTarget): string => {
|
||||||
const OUTFILE_BASE = 'server'
|
return target.replace('bun-', '')
|
||||||
|
|
||||||
const DEFAULT_TARGETS: BunTarget[] = [
|
|
||||||
'bun-windows-x64',
|
|
||||||
'bun-darwin-arm64',
|
|
||||||
'bun-linux-x64',
|
|
||||||
]
|
|
||||||
|
|
||||||
const suffixFor = (target: BunTarget) => target.replace('bun-', '')
|
|
||||||
|
|
||||||
const isTarget = (value: string): value is BunTarget =>
|
|
||||||
(ALL_TARGETS as readonly string[]).includes(value)
|
|
||||||
|
|
||||||
const parseTargets = (): BunTarget[] => {
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
const targets: string[] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const arg = args[i]
|
|
||||||
const next = args[i + 1]
|
|
||||||
if (arg === '--target' && next) {
|
|
||||||
targets.push(next)
|
|
||||||
i++
|
|
||||||
} else if (arg === '--targets' && next) {
|
|
||||||
targets.push(...next.split(','))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targets.length === 0) return DEFAULT_TARGETS
|
|
||||||
|
|
||||||
const invalid = targets.filter((t) => !isTarget(t))
|
|
||||||
if (invalid.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown target(s): ${invalid.join(', ')}\nAllowed: ${ALL_TARGETS.join(', ')}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return targets as BunTarget[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildOne = async (target: BunTarget) => {
|
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
|
||||||
const suffix = suffixFor(target)
|
|
||||||
const outfile = `${OUTFILE_BASE}-${suffix}`
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
const BuildConfigSchema = Schema.Struct({
|
||||||
entrypoints: [ENTRYPOINT],
|
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
|
||||||
outdir: OUTDIR,
|
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||||||
compile: {
|
outfile: Schema.String.pipe(Schema.nonEmptyString()),
|
||||||
outfile,
|
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
|
||||||
target,
|
})
|
||||||
},
|
|
||||||
|
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
|
||||||
|
|
||||||
|
const BuildResultSchema = Schema.Struct({
|
||||||
|
target: BunTargetSchema,
|
||||||
|
outputs: Schema.Array(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
|
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Models (使用 Data.TaggedError)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class CleanError extends Data.TaggedError('CleanError')<{
|
||||||
|
readonly dir: string
|
||||||
|
readonly cause: unknown
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
class BuildError extends Data.TaggedError('BuildError')<{
|
||||||
|
readonly target: BunTarget
|
||||||
|
readonly cause: unknown
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
class ConfigError extends Data.TaggedError('ConfigError')<{
|
||||||
|
readonly message: string
|
||||||
|
readonly cause: unknown
|
||||||
|
}> {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Services
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置服务
|
||||||
|
*/
|
||||||
|
class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||||
|
BuildConfigService,
|
||||||
|
BuildConfig
|
||||||
|
>() {
|
||||||
|
/**
|
||||||
|
* 从原始数据创建并验证配置
|
||||||
|
*/
|
||||||
|
static fromRaw = (raw: unknown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decoded = yield* Schema.decodeUnknown(BuildConfigSchema)(raw)
|
||||||
|
return decoded
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchAll((error) =>
|
||||||
|
Effect.fail(
|
||||||
|
new ConfigError({
|
||||||
|
message: '配置验证失败',
|
||||||
|
cause: error,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认配置 Layer
|
||||||
|
*/
|
||||||
|
static readonly Live = Layer.effect(
|
||||||
|
BuildConfigService,
|
||||||
|
BuildConfigService.fromRaw({
|
||||||
|
entrypoint: '.output/server/index.mjs',
|
||||||
|
outputDir: 'out',
|
||||||
|
outfile: 'server',
|
||||||
|
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||||
|
} satisfies BuildConfig),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件系统服务
|
||||||
|
*/
|
||||||
|
class FileSystemService extends Context.Tag('FileSystemService')<
|
||||||
|
FileSystemService,
|
||||||
|
{
|
||||||
|
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
|
||||||
|
}
|
||||||
|
>() {
|
||||||
|
static readonly Live = Layer.succeed(FileSystemService, {
|
||||||
|
cleanDir: (dir: string) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
await $`rm -rf ${dir}`
|
||||||
|
},
|
||||||
|
catch: (cause: unknown) =>
|
||||||
|
new CleanError({
|
||||||
|
dir,
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(
|
|
||||||
`Build failed for ${target}:\n${result.logs.map(String).join('\n')}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
outputs: result.outputs.map((o) => path.relative('.', o.path)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const main = async () => {
|
/**
|
||||||
const targets = parseTargets()
|
* 构建服务
|
||||||
|
*/
|
||||||
await rm(OUTDIR, { recursive: true, force: true })
|
class BuildService extends Context.Tag('BuildService')<
|
||||||
console.log(`✓ 已清理输出目录: ${OUTDIR}`)
|
BuildService,
|
||||||
|
{
|
||||||
// Bun cross-compile 不支持真正并行,逐目标串行构建
|
readonly buildForTarget: (
|
||||||
const results: Awaited<ReturnType<typeof buildOne>>[] = []
|
config: BuildConfig,
|
||||||
for (const target of targets) {
|
target: BunTarget,
|
||||||
const start = Date.now()
|
) => Effect.Effect<BuildResult, BuildError>
|
||||||
process.stdout.write(`🔨 构建 ${target}... `)
|
readonly buildAll: (
|
||||||
const result = await buildOne(target)
|
config: BuildConfig,
|
||||||
results.push(result)
|
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
|
||||||
console.log(`完成 (${Date.now() - start}ms)`)
|
|
||||||
}
|
}
|
||||||
|
>() {
|
||||||
|
static readonly Live = Layer.succeed(BuildService, {
|
||||||
|
buildForTarget: (config: BuildConfig, target: BunTarget) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||||
|
|
||||||
console.log('\n📦 构建完成:')
|
const output = yield* Effect.tryPromise({
|
||||||
for (const r of results) {
|
try: () =>
|
||||||
console.log(` ${r.target}:`)
|
Bun.build({
|
||||||
for (const p of r.outputs) {
|
entrypoints: [config.entrypoint],
|
||||||
console.log(` - ${p}`)
|
compile: {
|
||||||
}
|
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
|
||||||
}
|
target: target,
|
||||||
|
},
|
||||||
|
outdir: config.outputDir,
|
||||||
|
}),
|
||||||
|
catch: (cause: unknown) =>
|
||||||
|
new BuildError({
|
||||||
|
target,
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const paths = output.outputs.map((item: { path: string }) => item.path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
outputs: paths,
|
||||||
|
} satisfies BuildResult
|
||||||
|
}),
|
||||||
|
|
||||||
|
buildAll: (config: BuildConfig) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const effects = config.targets.map((target) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||||
|
|
||||||
|
const output = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
Bun.build({
|
||||||
|
entrypoints: [config.entrypoint],
|
||||||
|
compile: {
|
||||||
|
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
|
||||||
|
target: target,
|
||||||
|
},
|
||||||
|
outdir: config.outputDir,
|
||||||
|
}),
|
||||||
|
catch: (cause: unknown) =>
|
||||||
|
new BuildError({
|
||||||
|
target,
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const paths = output.outputs.map(
|
||||||
|
(item: { path: string }) => item.path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
outputs: paths,
|
||||||
|
} satisfies BuildResult
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
||||||
|
}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
/**
|
||||||
console.error('\n❌ 构建失败:')
|
* 报告服务
|
||||||
console.error(err instanceof Error ? err.message : err)
|
*/
|
||||||
|
class ReporterService extends Context.Tag('ReporterService')<
|
||||||
|
ReporterService,
|
||||||
|
{
|
||||||
|
readonly printSummary: (
|
||||||
|
results: ReadonlyArray<BuildResult>,
|
||||||
|
) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
>() {
|
||||||
|
static readonly Live = Layer.succeed(ReporterService, {
|
||||||
|
printSummary: (results: ReadonlyArray<BuildResult>) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log('\n📦 构建完成:')
|
||||||
|
for (const result of results) {
|
||||||
|
yield* Console.log(` ${result.target}:`)
|
||||||
|
for (const path of result.outputs) {
|
||||||
|
yield* Console.log(` - ${path}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Program
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const config = yield* BuildConfigService
|
||||||
|
const fs = yield* FileSystemService
|
||||||
|
const builder = yield* BuildService
|
||||||
|
const reporter = yield* ReporterService
|
||||||
|
|
||||||
|
// 1. 清理输出目录
|
||||||
|
yield* fs.cleanDir(config.outputDir)
|
||||||
|
yield* Console.log(`✓ 已清理输出目录: ${config.outputDir}`)
|
||||||
|
|
||||||
|
// 2. 并行构建所有目标
|
||||||
|
const results = yield* builder.buildAll(config)
|
||||||
|
|
||||||
|
// 3. 输出构建摘要
|
||||||
|
yield* reporter.printSummary(results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Layer Composition
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const MainLayer = Layer.mergeAll(
|
||||||
|
BuildConfigService.Live,
|
||||||
|
FileSystemService.Live,
|
||||||
|
BuildService.Live,
|
||||||
|
ReporterService.Live,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Runner
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const runnable = program.pipe(
|
||||||
|
Effect.provide(MainLayer),
|
||||||
|
Effect.catchTags({
|
||||||
|
CleanError: (error) =>
|
||||||
|
Console.error(`❌ 清理目录失败: ${error.dir}`, error.cause),
|
||||||
|
BuildError: (error) =>
|
||||||
|
Console.error(`❌ 构建失败 [${error.target}]:`, error.cause),
|
||||||
|
ConfigError: (error) =>
|
||||||
|
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
|
||||||
|
}),
|
||||||
|
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
||||||
|
)
|
||||||
|
|
||||||
|
Effect.runPromise(runnable).catch(() => {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { env } from '@/env'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/server/db/schema/index.ts',
|
schema: './src/server/db/schema/index.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'sqlite',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: env.DATABASE_URL,
|
url: env.DATABASE_URL,
|
||||||
},
|
},
|
||||||
|
|||||||
18
apps/server/drizzle/0000_messy_goliath.sql
Normal file
18
apps/server/drizzle/0000_messy_goliath.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `device_info` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`fingerprint` text NOT NULL,
|
||||||
|
`fingerprint_quality` text NOT NULL,
|
||||||
|
`license` text,
|
||||||
|
`license_activated_at` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `device_info_fingerprint_unique` ON `device_info` (`fingerprint`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `todo` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`completed` integer DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
10
apps/server/drizzle/0001_watery_mongu.sql
Normal file
10
apps/server/drizzle/0001_watery_mongu.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE `license_activation` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`fingerprint` text NOT NULL,
|
||||||
|
`license` text,
|
||||||
|
`license_activated_at` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `license_activation_fingerprint_unique` ON `license_activation` (`fingerprint`);
|
||||||
2
apps/server/drizzle/0002_dizzy_kingpin.sql
Normal file
2
apps/server/drizzle/0002_dizzy_kingpin.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE `device_info`;--> statement-breakpoint
|
||||||
|
DROP TABLE `todo`;
|
||||||
129
apps/server/drizzle/meta/0000_snapshot.json
Normal file
129
apps/server/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "09c29147-479a-4490-8223-db64c71c910f",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"device_info": {
|
||||||
|
"name": "device_info",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint_quality": {
|
||||||
|
"name": "fingerprint_quality",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"device_info_fingerprint_unique": {
|
||||||
|
"name": "device_info_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"name": "todo",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"name": "completed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
apps/server/drizzle/meta/0001_snapshot.json
Normal file
187
apps/server/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "14bad572-1fc5-489d-90f2-7560a7cad1f4",
|
||||||
|
"prevId": "09c29147-479a-4490-8223-db64c71c910f",
|
||||||
|
"tables": {
|
||||||
|
"device_info": {
|
||||||
|
"name": "device_info",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint_quality": {
|
||||||
|
"name": "fingerprint_quality",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"device_info_fingerprint_unique": {
|
||||||
|
"name": "device_info_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"license_activation": {
|
||||||
|
"name": "license_activation",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"license_activation_fingerprint_unique": {
|
||||||
|
"name": "license_activation_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"name": "todo",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"name": "completed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
apps/server/drizzle/meta/0002_snapshot.json
Normal file
76
apps/server/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "2bdd10cc-8e14-4843-931a-37b1d00f21a6",
|
||||||
|
"prevId": "14bad572-1fc5-489d-90f2-7560a7cad1f4",
|
||||||
|
"tables": {
|
||||||
|
"license_activation": {
|
||||||
|
"name": "license_activation",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"license_activation_fingerprint_unique": {
|
||||||
|
"name": "license_activation_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/server/drizzle/meta/_journal.json
Normal file
27
apps/server/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769409553227,
|
||||||
|
"tag": "0000_messy_goliath",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769409970060,
|
||||||
|
"tag": "0001_watery_mongu",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769412006512,
|
||||||
|
"tag": "0002_dizzy_kingpin",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"fix": "biome check --write",
|
"fix": "biome check --write .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@furtherverse/utils": "workspace:*",
|
"@isaacs/ttlcache": "catalog:",
|
||||||
"@orpc/client": "catalog:",
|
"@orpc/client": "catalog:",
|
||||||
"@orpc/contract": "catalog:",
|
"@orpc/contract": "catalog:",
|
||||||
"@orpc/openapi": "catalog:",
|
"@orpc/openapi": "catalog:",
|
||||||
@@ -29,25 +29,32 @@
|
|||||||
"@tanstack/react-start": "catalog:",
|
"@tanstack/react-start": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"drizzle-zod": "catalog:",
|
"drizzle-zod": "catalog:",
|
||||||
"postgres": "catalog:",
|
"ohash": "catalog:",
|
||||||
|
"better-sqlite3": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
"systeminformation": "catalog:",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@effect/platform": "catalog:",
|
||||||
|
"@effect/schema": "catalog:",
|
||||||
"@furtherverse/tsconfig": "workspace:*",
|
"@furtherverse/tsconfig": "workspace:*",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tanstack/devtools-vite": "catalog:",
|
"@tanstack/devtools-vite": "catalog:",
|
||||||
"@tanstack/react-devtools": "catalog:",
|
"@tanstack/react-devtools": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-router-devtools": "catalog:",
|
"@tanstack/react-router-devtools": "catalog:",
|
||||||
|
"@types/better-sqlite3": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"babel-plugin-react-compiler": "catalog:",
|
"babel-plugin-react-compiler": "catalog:",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"nitro": "catalog:",
|
"nitro": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-tsconfig-paths": "catalog:"
|
"vite-tsconfig-paths": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,5 @@ import { createTanstackQueryUtils } from '@orpc/tanstack-query'
|
|||||||
import { orpc as orpcClient } from './orpc.client'
|
import { orpc as orpcClient } from './orpc.client'
|
||||||
|
|
||||||
export const orpc = createTanstackQueryUtils(orpcClient, {
|
export const orpc = createTanstackQueryUtils(orpcClient, {
|
||||||
experimental_defaults: {
|
experimental_defaults: {},
|
||||||
todo: {
|
|
||||||
create: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
remove: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
DATABASE_URL: z.url(),
|
DATABASE_URL: z.string().min(1),
|
||||||
},
|
},
|
||||||
clientPrefix: 'VITE_',
|
clientPrefix: 'VITE_',
|
||||||
client: {
|
client: {
|
||||||
|
|||||||
109
apps/server/src/lib/crypto.ts
Normal file
109
apps/server/src/lib/crypto.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
constants,
|
||||||
|
createPrivateKey,
|
||||||
|
createPublicKey,
|
||||||
|
privateDecrypt,
|
||||||
|
publicEncrypt,
|
||||||
|
} from 'node:crypto'
|
||||||
|
|
||||||
|
// 对应 Java: RSA/ECB/OAEPWithSHA-256AndMGF1Padding
|
||||||
|
const OAEP_HASH = 'sha256'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Base64 编码的公钥加密明文
|
||||||
|
*
|
||||||
|
* 加密标准: RSA/ECB/OAEPWithSHA-256AndMGF1Padding (兼容 Java)
|
||||||
|
* 公钥格式: SPKI/DER (对应 Java X509EncodedKeySpec)
|
||||||
|
*
|
||||||
|
* @param plainText - 待加密的明文字符串
|
||||||
|
* @param publicKeyBase64 - Base64 编码的公钥 (DER/SPKI 格式)
|
||||||
|
* @returns Base64 编码的加密数据
|
||||||
|
* @throws {Error} 加密失败时抛出错误
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const publicKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...'
|
||||||
|
* const encrypted = encrypt('sensitive data', publicKey)
|
||||||
|
* console.log(encrypted) // "a3f5e8c2d1b4..."
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function encrypt(plainText: string, publicKeyBase64: string): string {
|
||||||
|
const buffer = Buffer.from(plainText, 'utf-8')
|
||||||
|
const keyBuffer = Buffer.from(publicKeyBase64, 'base64')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 先创建 KeyObject,在这里指定密钥的格式 (DER/SPKI)
|
||||||
|
const publicKey = createPublicKey({
|
||||||
|
key: keyBuffer,
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki', // 对应 Java X509EncodedKeySpec
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 使用 KeyObject 进行加密,在这里指定 Padding 模式
|
||||||
|
const encrypted = publicEncrypt(
|
||||||
|
{
|
||||||
|
key: publicKey,
|
||||||
|
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
oaepHash: OAEP_HASH,
|
||||||
|
},
|
||||||
|
buffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return encrypted.toString('base64')
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Encryption failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Base64 编码的私钥解密数据
|
||||||
|
*
|
||||||
|
* 解密标准: RSA/ECB/OAEPWithSHA-256AndMGF1Padding (兼容 Java)
|
||||||
|
* 私钥格式: PKCS8/DER (对应 Java PKCS8EncodedKeySpec)
|
||||||
|
*
|
||||||
|
* @param encryptedData - Base64 编码的加密数据
|
||||||
|
* @param privateKeyBase64 - Base64 编码的私钥 (DER/PKCS8 格式)
|
||||||
|
* @returns 解密后的明文字符串
|
||||||
|
* @throws {Error} 解密失败时抛出错误
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const privateKey = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...'
|
||||||
|
* const decrypted = decrypt('a3f5e8c2d1b4...', privateKey)
|
||||||
|
* console.log(decrypted) // "sensitive data"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function decrypt(
|
||||||
|
encryptedData: string,
|
||||||
|
privateKeyBase64: string,
|
||||||
|
): string {
|
||||||
|
const buffer = Buffer.from(encryptedData, 'base64')
|
||||||
|
const keyBuffer = Buffer.from(privateKeyBase64, 'base64')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 先创建 KeyObject,在这里指定密钥的格式 (DER/PKCS8)
|
||||||
|
const privateKey = createPrivateKey({
|
||||||
|
key: keyBuffer,
|
||||||
|
format: 'der',
|
||||||
|
type: 'pkcs8', // 对应 Java PKCS8EncodedKeySpec
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 使用 KeyObject 进行解密
|
||||||
|
const decrypted = privateDecrypt(
|
||||||
|
{
|
||||||
|
key: privateKey,
|
||||||
|
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
oaepHash: OAEP_HASH,
|
||||||
|
},
|
||||||
|
buffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return decrypted.toString('utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
233
apps/server/src/lib/fingerprint.ts
Normal file
233
apps/server/src/lib/fingerprint.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { TTLCache } from '@isaacs/ttlcache'
|
||||||
|
import { hash } from 'ohash'
|
||||||
|
import si from 'systeminformation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹质量等级
|
||||||
|
* - strong: 2+ 个强标识符可用(推荐用于生产授权)
|
||||||
|
* - medium: 1 个强标识符可用(可用但不理想)
|
||||||
|
* - weak: 无强标识符(仅适合开发/测试)
|
||||||
|
*/
|
||||||
|
export type FingerprintQuality = 'strong' | 'medium' | 'weak'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化的系统信息(用于机器码生成)
|
||||||
|
*/
|
||||||
|
export type NormalizedSystemInfo = {
|
||||||
|
/** 系统 UUID(最稳定的硬件标识符) */
|
||||||
|
systemUuid: string | null
|
||||||
|
/** 系统序列号 */
|
||||||
|
systemSerial: string | null
|
||||||
|
/** 主板序列号 */
|
||||||
|
baseboardSerial: string | null
|
||||||
|
/** 主板制造商 */
|
||||||
|
baseboardManufacturer: string | null
|
||||||
|
/** BIOS 版本 */
|
||||||
|
biosVersion: string | null
|
||||||
|
/** BIOS 供应商 */
|
||||||
|
biosVendor: string | null
|
||||||
|
/** CPU 品牌标识(用于质量评估) */
|
||||||
|
cpuBrand: string | null
|
||||||
|
/** 主硬盘序列号(可选,高稳定性) */
|
||||||
|
primaryDiskSerial?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹配置选项
|
||||||
|
*/
|
||||||
|
export type HardwareFingerprintOptions = {
|
||||||
|
/**
|
||||||
|
* 缓存 TTL(毫秒),默认 10 分钟
|
||||||
|
* 硬件信息变化频率极低,缓存可大幅提升性能
|
||||||
|
*/
|
||||||
|
cacheTtlMs?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否包含主硬盘序列号(默认 true)
|
||||||
|
* 注意:在容器/虚拟机环境可能获取失败
|
||||||
|
*/
|
||||||
|
includePrimaryDisk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹响应
|
||||||
|
*/
|
||||||
|
export type HardwareFingerprintResult = {
|
||||||
|
/** 机器码(HMAC-SHA256 哈希,64 字符十六进制) */
|
||||||
|
fingerprint: string
|
||||||
|
/** 指纹质量等级 */
|
||||||
|
quality: FingerprintQuality
|
||||||
|
/** 可用的强标识符数量 */
|
||||||
|
strongIdentifiersCount: number
|
||||||
|
/** 生成时间戳 */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存实例
|
||||||
|
const cache = new TTLCache<'fingerprint', HardwareFingerprintResult>({
|
||||||
|
ttl: 10 * 60 * 1000, // 10 minutes default
|
||||||
|
max: 1, // Only one fingerprint cached
|
||||||
|
})
|
||||||
|
|
||||||
|
// 防止并发重复请求
|
||||||
|
let inFlight: Promise<HardwareFingerprintResult> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指纹质量
|
||||||
|
*/
|
||||||
|
function computeQuality(info: NormalizedSystemInfo): {
|
||||||
|
quality: FingerprintQuality
|
||||||
|
count: number
|
||||||
|
} {
|
||||||
|
const strongKeys = [
|
||||||
|
info.systemUuid,
|
||||||
|
info.systemSerial,
|
||||||
|
info.baseboardSerial,
|
||||||
|
info.primaryDiskSerial,
|
||||||
|
].filter(Boolean).length
|
||||||
|
|
||||||
|
if (strongKeys >= 2) return { quality: 'strong', count: strongKeys }
|
||||||
|
if (strongKeys === 1) return { quality: 'medium', count: strongKeys }
|
||||||
|
return { quality: 'weak', count: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地收集标准化系统信息(容错处理)
|
||||||
|
*/
|
||||||
|
async function collectNormalizedInfo(
|
||||||
|
opts: HardwareFingerprintOptions,
|
||||||
|
): Promise<NormalizedSystemInfo> {
|
||||||
|
// 使用 Promise.allSettled 避免单点失败
|
||||||
|
const tasks = await Promise.allSettled([
|
||||||
|
si.uuid(),
|
||||||
|
si.system(),
|
||||||
|
si.baseboard(),
|
||||||
|
si.bios(),
|
||||||
|
si.cpu(),
|
||||||
|
opts.includePrimaryDisk !== false ? si.diskLayout() : Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const [uuidRes, systemRes, baseboardRes, biosRes, cpuRes, diskRes] = tasks
|
||||||
|
|
||||||
|
const uuid = uuidRes.status === 'fulfilled' ? uuidRes.value : null
|
||||||
|
const system = systemRes.status === 'fulfilled' ? systemRes.value : null
|
||||||
|
const baseboard =
|
||||||
|
baseboardRes.status === 'fulfilled' ? baseboardRes.value : null
|
||||||
|
const bios = biosRes.status === 'fulfilled' ? biosRes.value : null
|
||||||
|
const cpu = cpuRes.status === 'fulfilled' ? cpuRes.value : null
|
||||||
|
|
||||||
|
// 提取主硬盘序列号(通常是第一个物理磁盘)
|
||||||
|
let primaryDiskSerial: string | null = null
|
||||||
|
if (diskRes.status === 'fulfilled' && Array.isArray(diskRes.value)) {
|
||||||
|
const disks = diskRes.value as Array<{ serialNum?: string; type?: string }>
|
||||||
|
const physicalDisk = disks.find(
|
||||||
|
(d) => d.type !== 'USB' && d.serialNum && d.serialNum.trim(),
|
||||||
|
)
|
||||||
|
primaryDiskSerial = physicalDisk?.serialNum?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 系统级标识符(最稳定)
|
||||||
|
systemUuid: (system?.uuid ?? uuid?.hardware ?? null) || null,
|
||||||
|
systemSerial: (system?.serial ?? null) || null,
|
||||||
|
|
||||||
|
// 主板标识符(次稳定)
|
||||||
|
baseboardSerial: (baseboard?.serial ?? null) || null,
|
||||||
|
baseboardManufacturer: (baseboard?.manufacturer ?? null) || null,
|
||||||
|
|
||||||
|
// BIOS 信息(辅助识别)
|
||||||
|
biosVersion: (bios?.version ?? null) || null,
|
||||||
|
biosVendor: (bios?.vendor ?? null) || null,
|
||||||
|
|
||||||
|
// CPU 信息(辅助识别)
|
||||||
|
cpuBrand: (cpu?.brand ?? null) || null,
|
||||||
|
|
||||||
|
// 磁盘序列号(可选,高稳定性)
|
||||||
|
...(opts.includePrimaryDisk !== false ? { primaryDiskSerial } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
/**
|
||||||
|
* 获取硬件指纹(机器码)
|
||||||
|
*
|
||||||
|
* 适用场景:客户端部署的软件授权、机器绑定
|
||||||
|
*
|
||||||
|
* 安全说明:
|
||||||
|
* - 返回 SHA-256 哈希(Base64URL 编码,43 字符),不可逆推原始硬件信息
|
||||||
|
* - 使用 ohash 自动处理对象序列化和哈希
|
||||||
|
* - 客户端部署场景:客户可以看到代码,无法使用密钥加密
|
||||||
|
* - 安全性依赖硬件信息本身的不可伪造性(来自操作系统)
|
||||||
|
* - 自动缓存减少系统调用开销
|
||||||
|
*
|
||||||
|
* 稳定性:
|
||||||
|
* - 优先使用系统 UUID、序列号等不易变更的标识符
|
||||||
|
* - 避免网络接口等易变信息
|
||||||
|
* - 容错处理,部分信息缺失不影响生成
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const result = await getHardwareFingerprint({
|
||||||
|
* cacheTtlMs: 600000, // 10 分钟
|
||||||
|
* includePrimaryDisk: true,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* console.log(result.fingerprint) // "a3f5e8c2d1b4..."
|
||||||
|
* console.log(result.quality) // "strong"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function getHardwareFingerprint(
|
||||||
|
opts: HardwareFingerprintOptions,
|
||||||
|
): Promise<HardwareFingerprintResult> {
|
||||||
|
const ttl = opts.cacheTtlMs ?? 10 * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 返回缓存结果
|
||||||
|
const cached = cache.get('fingerprint')
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止并发重复请求
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight = (async () => {
|
||||||
|
// 收集标准化信息
|
||||||
|
const info = await collectNormalizedInfo(opts)
|
||||||
|
|
||||||
|
// 计算质量
|
||||||
|
const { quality, count } = computeQuality(info)
|
||||||
|
|
||||||
|
// 使用 ohash 生成指纹(自动序列化 + SHA-256 + Base64URL)
|
||||||
|
const fingerprint = hash({
|
||||||
|
v: 1, // 版本号,未来如需变更采集策略可递增
|
||||||
|
info,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: HardwareFingerprintResult = {
|
||||||
|
fingerprint,
|
||||||
|
quality,
|
||||||
|
strongIdentifiersCount: count,
|
||||||
|
timestamp: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cache.set('fingerprint', result, { ttl })
|
||||||
|
|
||||||
|
return result
|
||||||
|
})().finally(() => {
|
||||||
|
inFlight = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return inFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指纹缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
export function clearFingerprintCache(): void {
|
||||||
|
cache.clear()
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
40
apps/server/src/lib/license-init.ts
Normal file
40
apps/server/src/lib/license-init.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getDB } from '@/server/db'
|
||||||
|
import { licenseActivationTable } from '@/server/db/schema'
|
||||||
|
import { getHardwareFingerprint } from './fingerprint'
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
export async function ensureLicenseActivationInitialized(): Promise<void> {
|
||||||
|
if (initPromise) return initPromise
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const db = getDB()
|
||||||
|
const result = await getHardwareFingerprint({
|
||||||
|
cacheTtlMs: 10 * 60 * 1000,
|
||||||
|
includePrimaryDisk: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(licenseActivationTable)
|
||||||
|
.values({
|
||||||
|
fingerprint: result.fingerprint,
|
||||||
|
license: null,
|
||||||
|
licenseActivatedAt: null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: licenseActivationTable.fingerprint,
|
||||||
|
set: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize license activation:', error)
|
||||||
|
// 重置 promise 以便下次重试
|
||||||
|
initPromise = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
@@ -9,10 +9,22 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LicenseRouteImport } from './routes/license'
|
||||||
|
import { Route as FingerprintRouteImport } from './routes/fingerprint'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||||
|
|
||||||
|
const LicenseRoute = LicenseRouteImport.update({
|
||||||
|
id: '/license',
|
||||||
|
path: '/license',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const FingerprintRoute = FingerprintRouteImport.update({
|
||||||
|
id: '/fingerprint',
|
||||||
|
path: '/fingerprint',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -31,36 +43,58 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/api/$' | '/api/rpc/$'
|
fullPaths: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/api/$' | '/api/rpc/$'
|
to: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
|
id: '__root__' | '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
FingerprintRoute: typeof FingerprintRoute
|
||||||
|
LicenseRoute: typeof LicenseRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/license': {
|
||||||
|
id: '/license'
|
||||||
|
path: '/license'
|
||||||
|
fullPath: '/license'
|
||||||
|
preLoaderRoute: typeof LicenseRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/fingerprint': {
|
||||||
|
id: '/fingerprint'
|
||||||
|
path: '/fingerprint'
|
||||||
|
fullPath: '/fingerprint'
|
||||||
|
preLoaderRoute: typeof FingerprintRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -87,6 +121,8 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
FingerprintRoute: FingerprintRoute,
|
||||||
|
LicenseRoute: LicenseRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
301
apps/server/src/routes/fingerprint.tsx
Normal file
301
apps/server/src/routes/fingerprint.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { orpc } from '@/client/query-client'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/fingerprint')({
|
||||||
|
component: FingerprintPage,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
await context.queryClient.ensureQueryData(
|
||||||
|
orpc.fingerprint.get.queryOptions(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function FingerprintPage() {
|
||||||
|
const query = useSuspenseQuery(orpc.fingerprint.get.queryOptions())
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const data = query.data
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copied) {
|
||||||
|
const timer = setTimeout(() => setCopied(false), 2000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [copied])
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await navigator.clipboard.writeText(data.fingerprint)
|
||||||
|
setCopied(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityConfig = {
|
||||||
|
strong: {
|
||||||
|
label: '强',
|
||||||
|
color: 'text-green-600',
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
border: 'border-green-200',
|
||||||
|
icon: '✓',
|
||||||
|
description: '推荐用于生产授权',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: '中',
|
||||||
|
color: 'text-yellow-600',
|
||||||
|
bg: 'bg-yellow-50',
|
||||||
|
border: 'border-yellow-200',
|
||||||
|
icon: '!',
|
||||||
|
description: '可用但不理想',
|
||||||
|
},
|
||||||
|
weak: {
|
||||||
|
label: '弱',
|
||||||
|
color: 'text-red-600',
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
border: 'border-red-200',
|
||||||
|
icon: '×',
|
||||||
|
description: '仅适合开发/测试',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = qualityConfig[data.quality]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-50 py-12 px-4 sm:px-6 font-sans">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">
|
||||||
|
硬件指纹
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 text-lg">用于软件授权和机器码识别</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden">
|
||||||
|
{/* Quality Badge */}
|
||||||
|
<div className={`px-8 py-6 border-b ${config.bg} ${config.border}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-full ${config.bg} border-2 ${config.border} flex items-center justify-center text-2xl font-bold ${config.color}`}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-600">
|
||||||
|
指纹质量
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-semibold ${config.bg} ${config.color} border ${config.border}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold text-slate-900">
|
||||||
|
{data.strongIdentifiersCount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
强标识符
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fingerprint Display */}
|
||||||
|
<div className="px-8 py-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold text-slate-700 uppercase tracking-wider">
|
||||||
|
机器码
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
|
||||||
|
copied
|
||||||
|
? 'bg-green-100 text-green-700 border-2 border-green-300'
|
||||||
|
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied ? '已复制 ✓' : '复制'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl blur-sm opacity-0 group-hover:opacity-20 transition-opacity" />
|
||||||
|
<div className="relative bg-slate-900 rounded-xl p-6 font-mono text-sm break-all leading-relaxed text-slate-100 shadow-inner border-2 border-slate-800">
|
||||||
|
{data.fingerprint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<title>信息</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>SHA-256 哈希,43 字符 Base64URL 编码</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="px-8 py-6 bg-slate-50 border-t border-slate-100">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
生成时间
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium text-slate-900">
|
||||||
|
{new Date(data.timestamp).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
缓存状态
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-lg font-medium text-slate-900">
|
||||||
|
已缓存 10 分钟
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<title>安全</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-900">安全性</h3>
|
||||||
|
<p className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
使用 HMAC-SHA256 加密,无法反推原始硬件信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-purple-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<title>稳定性</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-900">稳定性</h3>
|
||||||
|
<p className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
基于系统 UUID、序列号等不易变更的标识符
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<title>性能</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-900">高性能</h3>
|
||||||
|
<p className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
自动缓存,减少系统调用开销
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Hint */}
|
||||||
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-2xl p-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<title>提示</title>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold text-blue-900">使用建议</h4>
|
||||||
|
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||||
|
<li>将机器码存储在授权服务器进行验证</li>
|
||||||
|
<li>建议配合用户账号进行双因素认证</li>
|
||||||
|
<li>同一台机器的指纹保持稳定,便于授权管理</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,206 +1,44 @@
|
|||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import type { ChangeEventHandler, FormEventHandler } from 'react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/client/query-client'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Todos,
|
component: Home,
|
||||||
loader: async ({ context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function Todos() {
|
function Home() {
|
||||||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
||||||
|
|
||||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
|
||||||
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
|
||||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
|
||||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
|
||||||
|
|
||||||
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newTodoTitle.trim()) {
|
|
||||||
createMutation.mutate({ title: newTodoTitle.trim() })
|
|
||||||
setNewTodoTitle('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
setNewTodoTitle(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id,
|
|
||||||
data: { completed: !currentCompleted },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteTodo = (id: string) => {
|
|
||||||
deleteMutation.mutate({ id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const todos = listQuery.data
|
|
||||||
const completedCount = todos.filter((todo) => todo.completed).length
|
|
||||||
const totalCount = todos.length
|
|
||||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
<div className="max-w-2xl mx-auto space-y-8 text-center">
|
||||||
{/* Header */}
|
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">
|
||||||
<div className="flex items-end justify-between">
|
License 管理系统
|
||||||
<div>
|
</h1>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
<p className="text-slate-500 text-lg">
|
||||||
我的待办
|
欢迎使用基于 TanStack Start + ORPC 的 License 管理系统
|
||||||
</h1>
|
</p>
|
||||||
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-2xl font-semibold text-slate-900">
|
|
||||||
{completedCount}
|
|
||||||
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
|
||||||
已完成
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Todo Form */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
|
||||||
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
<Link
|
||||||
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
to="/fingerprint"
|
||||||
<input
|
className="p-6 bg-white rounded-2xl shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-500/50 transition-all text-left group"
|
||||||
type="text"
|
>
|
||||||
value={newTodoTitle}
|
<h2 className="text-xl font-semibold text-slate-900 group-hover:text-indigo-600 transition-colors">
|
||||||
onChange={handleInputChange}
|
设备指纹
|
||||||
placeholder="添加新任务..."
|
</h2>
|
||||||
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
<p className="text-slate-500 mt-2 text-sm">
|
||||||
disabled={createMutation.isPending}
|
查询当前设备的硬件指纹信息,用于 License 绑定
|
||||||
/>
|
</p>
|
||||||
<button
|
</Link>
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
|
||||||
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? '添加中' : '添加'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Progress Bar (Only visible when there are tasks) */}
|
<Link
|
||||||
{totalCount > 0 && (
|
to="/license"
|
||||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
className="p-6 bg-white rounded-2xl shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-500/50 transition-all text-left group"
|
||||||
<div
|
>
|
||||||
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
<h2 className="text-xl font-semibold text-slate-900 group-hover:text-indigo-600 transition-colors">
|
||||||
style={{ width: `${progress}%` }}
|
License 激活
|
||||||
/>
|
</h2>
|
||||||
</div>
|
<p className="text-slate-500 mt-2 text-sm">
|
||||||
)}
|
管理和激活当前设备的 License 授权状态
|
||||||
|
</p>
|
||||||
{/* Todo List */}
|
</Link>
|
||||||
<div className="space-y-3">
|
|
||||||
{todos.length === 0 ? (
|
|
||||||
<div className="py-20 text-center">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 text-slate-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">
|
|
||||||
输入上方内容添加您的第一个任务
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
todos.map((todo) => (
|
|
||||||
<div
|
|
||||||
key={todo.id}
|
|
||||||
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
|
||||||
todo.completed ? 'bg-slate-50/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
|
||||||
todo.completed
|
|
||||||
? 'bg-indigo-500 border-indigo-500'
|
|
||||||
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.completed && (
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
className={`text-lg transition-all duration-200 truncate ${
|
|
||||||
todo.completed
|
|
||||||
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
|
||||||
: 'text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
|
||||||
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
|
||||||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteTodo(todo.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
463
apps/server/src/routes/license.tsx
Normal file
463
apps/server/src/routes/license.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { orpc } from '@/client/query-client'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/license')({
|
||||||
|
component: License,
|
||||||
|
})
|
||||||
|
|
||||||
|
function License() {
|
||||||
|
const [licenseInput, setLicenseInput] = useState('')
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false)
|
||||||
|
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// 获取激活状态
|
||||||
|
const { data } = useSuspenseQuery(orpc.license.getActivation.queryOptions())
|
||||||
|
|
||||||
|
// 激活 mutation
|
||||||
|
const activateMutation = useMutation({
|
||||||
|
...orpc.license.activate.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// 刷新数据
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.license.getActivation.key(),
|
||||||
|
})
|
||||||
|
// 清空输入
|
||||||
|
setLicenseInput('')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 反激活 mutation
|
||||||
|
const deactivateMutation = useMutation({
|
||||||
|
...orpc.license.deactivate.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.license.getActivation.key(),
|
||||||
|
})
|
||||||
|
setShowDeactivateConfirm(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
if (!licenseInput.trim()) return
|
||||||
|
activateMutation.mutate({ license: licenseInput.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = () => {
|
||||||
|
deactivateMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyFingerprint = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.fingerprint)
|
||||||
|
setCopySuccess(true)
|
||||||
|
setTimeout(() => setCopySuccess(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy fingerprint:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActivated = !!data.license
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
|
||||||
|
<div className="w-full max-w-2xl space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||||
|
License 激活管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2">管理您的设备授权与激活状态</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备信息卡片 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||||
|
<rect x="9" y="9" width="6" height="6"></rect>
|
||||||
|
<line x1="9" y1="1" x2="9" y2="4"></line>
|
||||||
|
<line x1="15" y1="1" x2="15" y2="4"></line>
|
||||||
|
<line x1="9" y1="20" x2="9" y2="23"></line>
|
||||||
|
<line x1="15" y1="20" x2="15" y2="23"></line>
|
||||||
|
<line x1="20" y1="9" x2="23" y2="9"></line>
|
||||||
|
<line x1="20" y1="14" x2="23" y2="14"></line>
|
||||||
|
<line x1="1" y1="9" x2="4" y2="9"></line>
|
||||||
|
<line x1="1" y1="14" x2="4" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
设备信息
|
||||||
|
</h2>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">
|
||||||
|
设备指纹 (Device Fingerprint)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<code className="flex-1 font-mono text-sm text-gray-700 break-all select-all">
|
||||||
|
{data.fingerprint}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyFingerprint}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||||
|
copySuccess
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copySuccess ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
已复制
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="9"
|
||||||
|
y="9"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
复制
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* License 激活卡片 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-purple-500"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
License 激活
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="license-key"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
License Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="license-key"
|
||||||
|
type="text"
|
||||||
|
value={licenseInput}
|
||||||
|
onChange={(e) => setLicenseInput(e.target.value)}
|
||||||
|
disabled={isActivated || activateMutation.isPending}
|
||||||
|
placeholder={
|
||||||
|
isActivated ? '已激活,无需输入' : '请输入您的 License Key'
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 outline-none transition-all disabled:bg-gray-100 disabled:text-gray-500 placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activateMutation.isError && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm flex items-start gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
|
</svg>
|
||||||
|
<span>激活失败: 请检查 License 是否正确或稍后重试</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={
|
||||||
|
isActivated ||
|
||||||
|
activateMutation.isPending ||
|
||||||
|
!licenseInput.trim()
|
||||||
|
}
|
||||||
|
className={`w-full px-6 py-2.5 rounded-lg font-medium text-white shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||||
|
isActivated
|
||||||
|
? 'bg-gray-300 cursor-not-allowed'
|
||||||
|
: activateMutation.isPending
|
||||||
|
? 'bg-purple-500 opacity-80 cursor-wait'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700 hover:shadow-md active:scale-[0.99] focus:ring-purple-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activateMutation.isPending ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="animate-spin h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
正在激活...
|
||||||
|
</span>
|
||||||
|
) : isActivated ? (
|
||||||
|
'已完成激活'
|
||||||
|
) : (
|
||||||
|
'立即激活'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 激活状态卡片 */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl shadow-sm border overflow-hidden transition-colors ${
|
||||||
|
isActivated
|
||||||
|
? 'bg-green-50/50 border-green-100'
|
||||||
|
: 'bg-white border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={isActivated ? 'text-green-500' : 'text-gray-400'}
|
||||||
|
>
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
激活状态
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isActivated ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-green-700 font-medium text-lg">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-green-500"
|
||||||
|
>
|
||||||
|
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path>
|
||||||
|
<path d="M9 12l2 2 4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>License 已激活</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-8 space-y-1">
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
当前 License:{' '}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{data.license}</span>
|
||||||
|
</p>
|
||||||
|
{data.licenseActivatedAt && (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
激活时间:{' '}
|
||||||
|
</span>
|
||||||
|
{new Date(data.licenseActivatedAt).toLocaleString(
|
||||||
|
'zh-CN',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showDeactivateConfirm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeactivateConfirm(true)}
|
||||||
|
className="mt-4 px-4 py-2 bg-red-500 text-white rounded-lg text-sm font-medium hover:bg-red-600 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
反激活
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-4 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
<p className="text-red-700 text-sm mb-3 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||||
|
<path d="M12 9v4" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
确定要反激活吗?此操作会清除当前 License。
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeactivate}
|
||||||
|
disabled={deactivateMutation.isPending}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 disabled:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{deactivateMutation.isPending
|
||||||
|
? '反激活中...'
|
||||||
|
: '确认反激活'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeactivateConfirm(false)}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{deactivateMutation.isError && (
|
||||||
|
<p className="text-red-500 mt-2 text-xs">
|
||||||
|
反激活失败,请重试
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-orange-500 shrink-0 mt-0.5"
|
||||||
|
>
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-orange-700 font-medium text-lg mb-1">
|
||||||
|
未激活
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
您的设备尚未激活。请在上方输入 License Key
|
||||||
|
进行激活以解锁全部功能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
apps/server/src/server/api/contracts/fingerprint.contract.ts
Normal file
26
apps/server/src/server/api/contracts/fingerprint.contract.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹质量等级
|
||||||
|
*/
|
||||||
|
const fingerprintQualitySchema = z.enum(['strong', 'medium', 'weak'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 硬件指纹响应 Schema
|
||||||
|
*/
|
||||||
|
const fingerprintResultSchema = z.object({
|
||||||
|
/** 机器码(HMAC-SHA256 哈希) */
|
||||||
|
fingerprint: z.string(),
|
||||||
|
/** 指纹质量等级 */
|
||||||
|
quality: fingerprintQualitySchema,
|
||||||
|
/** 可用的强标识符数量 */
|
||||||
|
strongIdentifiersCount: z.number(),
|
||||||
|
/** 生成时间戳 */
|
||||||
|
timestamp: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取硬件指纹契约
|
||||||
|
*/
|
||||||
|
export const get = oc.input(z.void()).output(fingerprintResultSchema)
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as todo from './todo.contract'
|
import * as fingerprint from './fingerprint.contract'
|
||||||
|
import * as license from './license.contract'
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
todo,
|
fingerprint,
|
||||||
|
license,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Contract = typeof contract
|
export type Contract = typeof contract
|
||||||
|
|||||||
18
apps/server/src/server/api/contracts/license.contract.ts
Normal file
18
apps/server/src/server/api/contracts/license.contract.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const getActivation = oc.input(z.void()).output(
|
||||||
|
z.object({
|
||||||
|
fingerprint: z.string(),
|
||||||
|
license: z.string().nullable(),
|
||||||
|
licenseActivatedAt: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const activate = oc
|
||||||
|
.input(z.object({ license: z.string().min(1) }))
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
|
||||||
|
export const deactivate = oc
|
||||||
|
.input(z.void())
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { oc } from '@orpc/contract'
|
|
||||||
import {
|
|
||||||
createInsertSchema,
|
|
||||||
createSelectSchema,
|
|
||||||
createUpdateSchema,
|
|
||||||
} from 'drizzle-zod'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { todoTable } from '@/server/db/schema'
|
|
||||||
|
|
||||||
const selectSchema = createSelectSchema(todoTable)
|
|
||||||
|
|
||||||
const insertSchema = createInsertSchema(todoTable).omit({
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateSchema = createUpdateSchema(todoTable).omit({
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const list = oc.input(z.void()).output(z.array(selectSchema))
|
|
||||||
|
|
||||||
export const create = oc.input(insertSchema).output(selectSchema)
|
|
||||||
|
|
||||||
export const update = oc
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
data: updateSchema,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(selectSchema)
|
|
||||||
|
|
||||||
export const remove = oc
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(z.void())
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { os } from '@orpc/server'
|
import { os } from '@orpc/server'
|
||||||
|
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
|
||||||
import { getDB } from '@/server/db'
|
import { getDB } from '@/server/db'
|
||||||
|
|
||||||
export const db = os.middleware(async ({ context, next }) => {
|
export const dbProvider = os.middleware(async ({ context, next }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
return next({
|
return next({
|
||||||
context: {
|
context: {
|
||||||
...context,
|
...context,
|
||||||
@@ -9,3 +11,5 @@ export const db = os.middleware(async ({ context, next }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const db = dbProvider
|
||||||
|
|||||||
11
apps/server/src/server/api/routers/fingerprint.router.ts
Normal file
11
apps/server/src/server/api/routers/fingerprint.router.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { getHardwareFingerprint } from '@/lib/fingerprint'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const get = os.fingerprint.get.handler(async () => {
|
||||||
|
const result = await getHardwareFingerprint({
|
||||||
|
cacheTtlMs: 10 * 60 * 1000, // 10 分钟缓存
|
||||||
|
includePrimaryDisk: true, // 包含主硬盘序列号以提高稳定性
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { os } from '../server'
|
import { os } from '../server'
|
||||||
import * as todo from './todo.router'
|
import * as fingerprint from './fingerprint.router'
|
||||||
|
import * as license from './license.router'
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
todo,
|
fingerprint,
|
||||||
|
license,
|
||||||
})
|
})
|
||||||
|
|||||||
67
apps/server/src/server/api/routers/license.router.ts
Normal file
67
apps/server/src/server/api/routers/license.router.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
|
||||||
|
import { licenseActivationTable } from '@/server/db/schema'
|
||||||
|
import { dbProvider } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const getActivation = os.license.getActivation
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
|
|
||||||
|
const record = await context.db.query.licenseActivationTable.findFirst()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('License activation record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: record.fingerprint,
|
||||||
|
license: record.license,
|
||||||
|
licenseActivatedAt: record.licenseActivatedAt?.getTime() ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const activate = os.license.activate
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context, input }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
|
|
||||||
|
const record = await context.db.query.licenseActivationTable.findFirst()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('License activation record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.update(licenseActivationTable)
|
||||||
|
.set({
|
||||||
|
license: input.license,
|
||||||
|
licenseActivatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(licenseActivationTable.id, record.id))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deactivate = os.license.deactivate
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
|
|
||||||
|
const record = await context.db.query.licenseActivationTable.findFirst()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('License activation record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.update(licenseActivationTable)
|
||||||
|
.set({
|
||||||
|
license: null,
|
||||||
|
licenseActivatedAt: null,
|
||||||
|
})
|
||||||
|
.where(eq(licenseActivationTable.id, record.id))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { ORPCError } from '@orpc/server'
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { todoTable } from '@/server/db/schema'
|
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const list = os.todo.list.use(db).handler(async ({ context }) => {
|
|
||||||
const todos = await context.db.query.todoTable.findMany({
|
|
||||||
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
|
|
||||||
})
|
|
||||||
return todos
|
|
||||||
})
|
|
||||||
|
|
||||||
export const create = os.todo.create
|
|
||||||
.use(db)
|
|
||||||
.handler(async ({ context, input }) => {
|
|
||||||
const [newTodo] = await context.db
|
|
||||||
.insert(todoTable)
|
|
||||||
.values(input)
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
if (!newTodo) {
|
|
||||||
throw new ORPCError('NOT_FOUND')
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTodo
|
|
||||||
})
|
|
||||||
|
|
||||||
export const update = os.todo.update
|
|
||||||
.use(db)
|
|
||||||
.handler(async ({ context, input }) => {
|
|
||||||
const [updatedTodo] = await context.db
|
|
||||||
.update(todoTable)
|
|
||||||
.set(input.data)
|
|
||||||
.where(eq(todoTable.id, input.id))
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
if (!updatedTodo) {
|
|
||||||
throw new ORPCError('NOT_FOUND')
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedTodo
|
|
||||||
})
|
|
||||||
|
|
||||||
export const remove = os.todo.remove
|
|
||||||
.use(db)
|
|
||||||
.handler(async ({ context, input }) => {
|
|
||||||
await context.db.delete(todoTable).where(eq(todoTable.id, input.id))
|
|
||||||
})
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
import * as schema from '@/server/db/schema'
|
import * as schema from '@/server/db/schema'
|
||||||
|
|
||||||
export const createDB = () =>
|
export const createDB = () => {
|
||||||
drizzle({
|
const dbPath = env.DATABASE_URL
|
||||||
connection: {
|
if (dbPath !== ':memory:') {
|
||||||
url: env.DATABASE_URL,
|
mkdirSync(dirname(dbPath), { recursive: true })
|
||||||
prepare: true,
|
}
|
||||||
},
|
const sqlite = new Database(dbPath)
|
||||||
schema,
|
return drizzle(sqlite, { schema })
|
||||||
})
|
}
|
||||||
|
|
||||||
export type DB = ReturnType<typeof createDB>
|
export type DB = ReturnType<typeof createDB>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './todo'
|
export * from './license-activation'
|
||||||
|
|||||||
9
apps/server/src/server/db/schema/license-activation.ts
Normal file
9
apps/server/src/server/db/schema/license-activation.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
import { generatedFields } from './utils/field'
|
||||||
|
|
||||||
|
export const licenseActivationTable = sqliteTable('license_activation', {
|
||||||
|
...generatedFields,
|
||||||
|
fingerprint: text('fingerprint').notNull().unique(),
|
||||||
|
license: text('license'),
|
||||||
|
licenseActivatedAt: integer('license_activated_at', { mode: 'timestamp_ms' }),
|
||||||
|
})
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
|
|
||||||
import { generatedFields } from './utils/field'
|
|
||||||
|
|
||||||
export const todoTable = pgTable('todo', {
|
|
||||||
...generatedFields,
|
|
||||||
title: text('title').notNull(),
|
|
||||||
completed: boolean('completed').notNull().default(false),
|
|
||||||
})
|
|
||||||
@@ -1,37 +1,25 @@
|
|||||||
import { sql } from 'drizzle-orm'
|
import { integer, text } from 'drizzle-orm/sqlite-core'
|
||||||
import { timestamp, uuid } from 'drizzle-orm/pg-core'
|
|
||||||
import { v7 as uuidv7 } from 'uuid'
|
import { v7 as uuidv7 } from 'uuid'
|
||||||
|
|
||||||
// id
|
// id
|
||||||
|
|
||||||
export const id = (name: string) => uuid(name)
|
export const id = (name: string) => text(name)
|
||||||
export const pk = (name: string, strategy?: 'native' | 'extension') => {
|
export const pk = (name: string) =>
|
||||||
switch (strategy) {
|
id(name)
|
||||||
// PG 18+
|
.primaryKey()
|
||||||
case 'native':
|
.$defaultFn(() => uuidv7())
|
||||||
return id(name).primaryKey().default(sql`uuidv7()`)
|
|
||||||
|
|
||||||
// PG 13+ with extension
|
|
||||||
case 'extension':
|
|
||||||
return id(name).primaryKey().default(sql`uuid_generate_v7()`)
|
|
||||||
|
|
||||||
// Any PG version
|
|
||||||
default:
|
|
||||||
return id(name)
|
|
||||||
.primaryKey()
|
|
||||||
.$defaultFn(() => uuidv7())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// timestamp
|
// timestamp
|
||||||
|
|
||||||
export const createdAt = (name = 'created_at') =>
|
export const createdAt = (name = 'created_at') =>
|
||||||
timestamp(name, { withTimezone: true }).notNull().defaultNow()
|
integer(name, { mode: 'timestamp_ms' })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date())
|
||||||
|
|
||||||
export const updatedAt = (name = 'updated_at') =>
|
export const updatedAt = (name = 'updated_at') =>
|
||||||
timestamp(name, { withTimezone: true })
|
integer(name, { mode: 'timestamp_ms' })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow()
|
.$defaultFn(() => new Date())
|
||||||
.$onUpdateFn(() => new Date())
|
.$onUpdateFn(() => new Date())
|
||||||
|
|
||||||
// generated fields
|
// generated fields
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
"$schema": "../../node_modules/turbo/schema.json",
|
"$schema": "../../node_modules/turbo/schema.json",
|
||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
|
||||||
"env": ["NODE_ENV", "VITE_*"],
|
|
||||||
"outputs": [".output/**"]
|
|
||||||
},
|
|
||||||
"compile": {
|
"compile": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
"outputs": ["out/**"]
|
"outputs": ["out/**"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
[tools]
|
[tools]
|
||||||
|
node = "latest"
|
||||||
bun = "1"
|
bun = "1"
|
||||||
node = "24"
|
rust = 'latest'
|
||||||
|
|||||||
46
package.json
46
package.json
@@ -10,17 +10,20 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"compile": "turbo run compile",
|
"compile": "turbo run compile",
|
||||||
|
"deploy": "turbo run deploy",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"fix": "turbo run fix",
|
"fix": "turbo run fix",
|
||||||
"typecheck": "turbo run typecheck"
|
"typecheck": "turbo run typecheck"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.14",
|
"@biomejs/biome": "^2.3.12",
|
||||||
"turbo": "^2.8.3",
|
"turbo": "^2.7.6"
|
||||||
"typescript": "^5.9.3"
|
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@biomejs/biome": "^2.3.11",
|
"@biomejs/biome": "^2.3.11",
|
||||||
|
"@effect/platform": "^0.94.2",
|
||||||
|
"@effect/schema": "^0.75.5",
|
||||||
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@orpc/client": "^1.13.4",
|
"@orpc/client": "^1.13.4",
|
||||||
"@orpc/contract": "^1.13.4",
|
"@orpc/contract": "^1.13.4",
|
||||||
"@orpc/openapi": "^1.13.4",
|
"@orpc/openapi": "^1.13.4",
|
||||||
@@ -29,37 +32,34 @@
|
|||||||
"@orpc/zod": "^1.13.4",
|
"@orpc/zod": "^1.13.4",
|
||||||
"@t3-oss/env-core": "^0.13.10",
|
"@t3-oss/env-core": "^0.13.10",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/devtools-vite": "^0.5.0",
|
"@tanstack/devtools-vite": "^0.4.1",
|
||||||
"@tanstack/react-devtools": "^0.9.4",
|
"@tanstack/react-devtools": "^0.9.2",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
"@tanstack/react-router": "^1.158.4",
|
"@tanstack/react-router": "^1.157.14",
|
||||||
"@tanstack/react-router-devtools": "^1.158.4",
|
"@tanstack/react-router-devtools": "^1.157.14",
|
||||||
"@tanstack/react-router-ssr-query": "^1.158.4",
|
"@tanstack/react-router-ssr-query": "^1.157.14",
|
||||||
"@tanstack/react-start": "^1.159.0",
|
"@tanstack/react-start": "^1.157.14",
|
||||||
"@types/bun": "^1.3.8",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/node": "^24.3.0",
|
"@types/bun": "^1.3.6",
|
||||||
"@vitejs/plugin-react": "^5.1.3",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
"electrobun": "^1.12.0-beta.1",
|
"effect": "^3.19.15",
|
||||||
"nitro": "npm:nitro-nightly@3.0.1-20260206-171553-bc737c0c",
|
"nitro": "npm:nitro-nightly@3.0.1-20260125-215009-8882bc9e",
|
||||||
"ohash": "^2.0.11",
|
"ohash": "^2.0.11",
|
||||||
"postgres": "^3.4.8",
|
"better-sqlite3": "^11.8.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.3",
|
||||||
"systeminformation": "^5.30.7",
|
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"turbo": "^2.7.5",
|
"turbo": "^2.7.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite": "^8.0.0-beta.13",
|
"systeminformation": "^5.30.6",
|
||||||
|
"vite": "^8.0.0-beta.10",
|
||||||
"vite-tsconfig-paths": "^6.0.5",
|
"vite-tsconfig-paths": "^6.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@types/node": "catalog:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@furtherverse/utils",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"imports": {
|
|
||||||
"#*": "./src/*"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./*": "./src/*.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"ohash": "catalog:",
|
|
||||||
"systeminformation": "catalog:"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@furtherverse/tsconfig": "workspace:*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { hash } from 'ohash'
|
|
||||||
import si from 'systeminformation'
|
|
||||||
|
|
||||||
async function getSystemInfo() {
|
|
||||||
const [uuid, baseboard, bios, system, diskLayout, networkInterfaces] =
|
|
||||||
await Promise.all([
|
|
||||||
si.uuid(),
|
|
||||||
si.baseboard(),
|
|
||||||
si.bios(),
|
|
||||||
si.system(),
|
|
||||||
si.diskLayout(),
|
|
||||||
si.networkInterfaces(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
uuid,
|
|
||||||
baseboard,
|
|
||||||
bios,
|
|
||||||
system,
|
|
||||||
diskLayout,
|
|
||||||
networkInterfaces,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getHardwareFingerprint() {
|
|
||||||
const systemInfo = await getSystemInfo()
|
|
||||||
|
|
||||||
return hash(systemInfo)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './fingerprint'
|
|
||||||
22
turbo.json
22
turbo.json
@@ -1,28 +1,30 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/turbo/schema.json",
|
"$schema": "./node_modules/turbo/schema.json",
|
||||||
"dangerouslyDisablePackageManagerCheck": true,
|
"dangerouslyDisablePackageManagerCheck": true,
|
||||||
"globalDependencies": ["bun.lock"],
|
"globalDependencies": [
|
||||||
|
"package.json",
|
||||||
|
"bun.lock",
|
||||||
|
"turbo.json",
|
||||||
|
"biome.json"
|
||||||
|
],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"]
|
"outputs": ["dist/**", ".output/**"]
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"dependsOn": ["build"]
|
"dependsOn": ["build"],
|
||||||
|
"outputs": ["out/**"]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"fix": {
|
"fix": {
|
||||||
"cache": false
|
"outputs": []
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"inputs": [
|
"dependsOn": ["^typecheck"],
|
||||||
"package.json",
|
"inputs": ["tsconfig.json", "tsconfig.*.json", "**/*.{ts,tsx}"],
|
||||||
"tsconfig.json",
|
|
||||||
"tsconfig.*.json",
|
|
||||||
"**/*.{ts,tsx,d.ts}"
|
|
||||||
],
|
|
||||||
"outputs": []
|
"outputs": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user