From afd45bb2a69db5ec8c9e7487d718f165a005ea89 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:04:56 +0700 Subject: [PATCH 01/26] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f6d3d0e..a01eb7b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage/ # Temporary folders tmp/ temp/ +.worktrees/ # Optional npm cache directory .npm From 37b38ca48b839663b692db3b39a08db8258615d2 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:06:28 +0700 Subject: [PATCH 02/26] docs: add Rootline v2 implementation plan --- .gitignore | 1 + .../2026-08-15-rootline-desktop-cli-v2.md | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md diff --git a/.gitignore b/.gitignore index a01eb7b..cf713be 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage/ tmp/ temp/ .worktrees/ +.superpowers/sdd/ # Optional npm cache directory .npm diff --git a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md new file mode 100644 index 0000000..e650f69 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md @@ -0,0 +1,55 @@ +# Rootline Desktop + CLI v2 Implementation Plan + +> **For agentic workers:** Use subagent-driven development and test-driven development. Every production behavior starts with a focused failing test, and every task ends with its own verification and commit. + +**Goal:** Turn `folder-structure-sync` into Rootline by baole.space: a safe visual desktop directory-structure synchronizer for macOS and Windows, backed by the same core as the backwards-compatible CLI and an optional hosted profile-sync API. + +**Architecture:** A pnpm workspace contains an environment-independent TypeScript core, the published Node CLI, shared cloud contracts, a React/Tauri 2 desktop app, and a NestJS/Prisma API. Filesystem data and run history remain local; only explicitly saved profiles are synchronized. + +**Tech stack:** Node.js 20, pnpm, TypeScript, Vitest, Commander, React/Vite, Tauri 2/Rust, SQLite, oidc-client-ts, NestJS 11, Prisma/PostgreSQL. + +## Global Constraints + +- Product display name is `Rootline by baole.space`; Tauri identifier is `space.baole.rootline`. +- Keep npm package `folder-structure-sync` and binary `folder-sync`; release all workspace packages as product version `2.0.0` where public. +- Sync is one-way source to target and additive only: create missing directories, never copy/delete files or directories. +- Source and target may not be equal, ancestors, or descendants. Skip symlinks and Windows junctions. +- Node minimum is 20. CLI keeps `--dry-run`, `--verbose`, and `--auto`, and adds `--config` and `--json`. +- Desktop v1 targets macOS Universal and Windows x64/ARM64 only. No Linux, mobile, watcher, scheduler, mirror mode, or CLI cloud profiles. +- Desktop works offline. Optional Authentik login synchronizes complete profiles including absolute paths. +- Cloud conflict policy is server-commit-arrival last-write-wins. Deletes are tombstones. Account-data deletion rotates an epoch so stale devices cannot silently recreate deleted cloud data. +- Absolute paths and tokens never appear in API production logs. Tokens are not stored in localStorage. +- Keep the existing ISC license. No usage telemetry in v1. +- External stable-release gates (Authentik registration, PostgreSQL encrypted deployment, Apple notarization, Windows signing, updater key) must be documented and fail closed when secrets are absent. + +--- + +### Task 1: Recover the Published Baseline and Establish Workspace Contracts + +Recover the exact npm `1.1.0` tarball, verify its registry integrity, document its unreachable original git head, and preserve the `.ignore` directory-pruning behavior without rewriting history. Convert the repository into a pnpm workspace with root orchestration, shared TypeScript/Vitest configuration, `packages/contracts`, and skeleton packages/apps. Define and test the public domain/cloud types and shared error codes. Do not implement filesystem algorithms, UI behavior, database persistence, or API endpoints yet. + +Acceptance: frozen pnpm install succeeds; contracts tests, typecheck, and package builds pass; npm `1.1.0` recovery evidence is checked into docs; old root implementation remains available as migration evidence but is no longer the future package entry point. + +### Task 2: Implement the Shared Core and Backwards-Compatible CLI v2 + +Use TDD to implement normalized relative paths, exclusion matching, deterministic snapshots/plans/fingerprints, subtree dependency selection, overlap/traversal validation, and shared error objects. Implement the Node filesystem adapter, explicit config resolution, symlink/junction skipping, target case-comparison policy, revalidation, mkdir result statuses, and cancellation boundaries. Build the CLI with the required legacy/new flags, JSON/no-prompt behavior, version sourced from package metadata, and exact exit codes. + +Acceptance: unit/integration/smoke tests cover `.git` versus `.github`, missing target, overlapping roots, symlinks, unreadable paths, stale plans, partial failures, config precedence, auto mode, JSON output, packaging and exit codes. `pnpm --filter folder-structure-sync test` and pack/install smoke pass. + +### Task 3: Implement Rootline Desktop Offline Workflow + +Build the React/Vite/Tauri 2 desktop shell and Rust native boundary. Native code owns folder dialogs, filesystem scan/apply/revalidation/cancellation, filesystem case semantics, SQLite migrations/repositories, device identity, mutation outbox, sync cursor, and capped run history. Build the approved profile rail and `Choose -> Scan -> Review -> Apply` workflow with virtualized diff tree, filters, subtree selection, rebind state, results, Vietnamese/English copy, system light/dark, keyboard/accessibility behavior and reduced motion. + +Acceptance: Rust unit/integration tests cover native safety and SQLite migration/repository behavior; React tests cover workflow, error/empty/loading states, keyboard/focus and 50,000-folder virtualization fixture; Tauri development build starts on macOS and production build succeeds for the host target. + +### Task 4: Implement Authentik and Hosted Profile Sync + +Implement the Rootline public-client OIDC flow with Authorization Code + PKCE, system browser, `rootline://auth/callback`, strict state/nonce/callback validation, Stronghold-backed state/token persistence and OS-protected per-install vault key. Implement the NestJS/Prisma/PostgreSQL API with static-JWKS RS256 validation, tenant scoping by `sub`, permission `rootline:profiles:sync`, DTO limits, rate/body limits, `GET /healthz`, `POST /v1/sync`, and `DELETE /v1/account-data`. Implement idempotent mutation receipts, per-user revisions, delta cursors, LWW, tombstones, epoch reset, offline outbox replay and account-data removal UX. + +Acceptance: API tests with real PostgreSQL cover invalid issuer/audience, cross-tenant access, idempotency, arrival-order LWW, cursor deltas, tombstones, offline replay, limits and reset-required behavior; desktop auth/sync tests prove raw tokens are hidden from components, offline usage is unaffected, and sign-out keeps or explicitly removes local synced data. + +### Task 5: Harden CI, Distribution, Documentation, and Ecosystem Integration + +Add CI for lint/typecheck/tests/builds, Rust fmt/clippy/test, npm tarball smoke and Tauri macOS/Windows build matrices. Add fail-closed release workflows for npm, API image/migration health, signed/notarized installers and signed updater manifests. Update README, security/privacy, architecture, configuration/migration and operations documentation. Add a Rootline product/download entry to the sibling `baole.space` portal using its existing catalog pattern without modifying unrelated portal work. + +Acceptance: all local gates pass; release workflow validation passes without publishing; missing signing/auth/deployment secrets block stable jobs with actionable messages; final branch review has no Critical/Important findings. External credentials and live provider/signing operations are reported as explicit blocked gates, not claimed complete. From 07f1b623ced1856daf9397668332883ed4e7e6cf Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:14:56 +0700 Subject: [PATCH 03/26] feat: establish Rootline workspace contracts --- apps/api/package.json | 12 + apps/api/src/index.ts | 1 + apps/api/tsconfig.json | 8 + apps/desktop/package.json | 12 + apps/desktop/src/index.ts | 1 + apps/desktop/tsconfig.json | 8 + docs/README.md | 4 + docs/baseline/folder-structure-sync-1.1.0.tgz | Bin 0 -> 9101 bytes docs/baseline/npm-1.1.0-recovery.md | 49 + .../2026-08-15-rootline-desktop-cli-v2.md | 5 + package.json | 78 +- packages/cli/package.json | 15 + packages/cli/src/index.ts | 1 + packages/cli/tsconfig.json | 8 + packages/contracts/package.json | 20 + packages/contracts/src/index.ts | 135 +++ packages/contracts/test/contracts.test.ts | 47 + .../contracts/test/contracts.types.test.ts | 51 + packages/contracts/tsconfig.json | 8 + packages/contracts/tsconfig.test.json | 8 + packages/contracts/vitest.config.ts | 7 + packages/core/package.json | 12 + packages/core/src/index.ts | 1 + packages/core/tsconfig.json | 8 + pnpm-lock.yaml | 1027 +++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 15 + vitest.workspace.ts | 6 + 28 files changed, 1486 insertions(+), 64 deletions(-) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src/index.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 docs/README.md create mode 100644 docs/baseline/folder-structure-sync-1.1.0.tgz create mode 100644 docs/baseline/npm-1.1.0-recovery.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/test/contracts.test.ts create mode 100644 packages/contracts/test/contracts.types.test.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/contracts/tsconfig.test.json create mode 100644 packages/contracts/vitest.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vitest.workspace.ts diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..fe32afa --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,12 @@ +{ + "name": "@rootline/api", + "version": "2.0.0", + "private": true, + "description": "Rootline hosted profile synchronization API", + "license": "ISC", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..01f6213 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,12 @@ +{ + "name": "@rootline/desktop", + "version": "2.0.0", + "private": true, + "description": "Rootline by baole.space desktop application", + "license": "ISC", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/desktop/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a5d5157 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,4 @@ +# Rootline documentation + +- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) +- [npm `folder-structure-sync@1.1.0` recovery](baseline/npm-1.1.0-recovery.md) diff --git a/docs/baseline/folder-structure-sync-1.1.0.tgz b/docs/baseline/folder-structure-sync-1.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..102b3ae8fa8a15622875c42bfe747ad52bb8f9fd GIT binary patch literal 9101 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 LU?^g70D1rbDJ${1 literal 0 HcmV?d00001 diff --git a/docs/baseline/npm-1.1.0-recovery.md b/docs/baseline/npm-1.1.0-recovery.md new file mode 100644 index 0000000..9eeb167 --- /dev/null +++ b/docs/baseline/npm-1.1.0-recovery.md @@ -0,0 +1,49 @@ +# npm `folder-structure-sync@1.1.0` recovery + +The checked-in archive [folder-structure-sync-1.1.0.tgz](./folder-structure-sync-1.1.0.tgz) is the byte-for-byte tarball recovered from the public npm registry on 2026-08-15. It is retained as migration evidence only; it is not a workspace package or a future release entry point. + +## Registry provenance and integrity + +The registry metadata returned the following values: + +| Field | Value | +| --- | --- | +| Tarball | `https://registry.npmjs.org/folder-structure-sync/-/folder-structure-sync-1.1.0.tgz` | +| `dist.integrity` | `sha512-DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w==` | +| `dist.shasum` | `5cbc1470b492f36bad11653f2b8545a62daf5929` | +| `gitHead` | `a9cb35279f65db6e939c884a0503f2e13b3a5d93` | + +The downloaded archive verified with both commands: + +```text +$ shasum -a 1 docs/baseline/folder-structure-sync-1.1.0.tgz +5cbc1470b492f36bad11653f2b8545a62daf5929 docs/baseline/folder-structure-sync-1.1.0.tgz + +$ openssl dgst -sha512 -binary docs/baseline/folder-structure-sync-1.1.0.tgz | openssl base64 -A +DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w== +``` + +## Unreachable original Git head + +The registry records `a9cb35279f65db6e939c884a0503f2e13b3a5d93` as the publish head, but it is not present in this clone: + +```text +$ git cat-file -e a9cb35279f65db6e939c884a0503f2e13b3a5d93^{commit} +fatal: Not a valid object name a9cb35279f65db6e939c884a0503f2e13b3a5d93^{commit} + +$ git branch -a --contains a9cb35279f65db6e939c884a0503f2e13b3a5d93 +error: no such commit a9cb35279f65db6e939c884a0503f2e13b3a5d93 +``` + +This recovery is a new commit and does not amend, reset, or otherwise rewrite repository history. + +## Legacy behavior retained for migration + +The complete published source remains available inside the checked-in archive. The existing root `index.js` and `sync-config.json` are intentionally left untouched as the pre-workspace migration evidence. + +In the recovered `1.1.0` source, recursive scanning checks for a `.ignore` file before reading a directory. Its presence stops scanning that directory and all of its descendants; a nested directory has already been discovered by its parent, so the marker prunes children rather than the nested directory itself. Future core work must preserve that pruning semantics deliberately, rather than changing legacy history to retrofit it. + +## Related + +- [Rootline documentation](../README.md) - Documentation navigation. +- [Rootline Desktop + CLI v2 implementation plan](../superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - The migration plan this evidence supports. diff --git a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md index e650f69..87e4276 100644 --- a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md +++ b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md @@ -53,3 +53,8 @@ Acceptance: API tests with real PostgreSQL cover invalid issuer/audience, cross- Add CI for lint/typecheck/tests/builds, Rust fmt/clippy/test, npm tarball smoke and Tauri macOS/Windows build matrices. Add fail-closed release workflows for npm, API image/migration health, signed/notarized installers and signed updater manifests. Update README, security/privacy, architecture, configuration/migration and operations documentation. Add a Rootline product/download entry to the sibling `baole.space` portal using its existing catalog pattern without modifying unrelated portal work. Acceptance: all local gates pass; release workflow validation passes without publishing; missing signing/auth/deployment secrets block stable jobs with actionable messages; final branch review has no Critical/Important findings. External credentials and live provider/signing operations are reported as explicit blocked gates, not claimed complete. + +## Related + +- [Rootline documentation](../../README.md) - Documentation navigation. +- [npm `folder-structure-sync@1.1.0` recovery](../../baseline/npm-1.1.0-recovery.md) - The published baseline retained for migration. diff --git a/package.json b/package.json index 3bb8106..976a6bd 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,20 @@ { - "name": "folder-structure-sync", - "version": "1.0.0", - "description": "🚀 Interactive CLI tool for syncing folder structures with smart selection, dependency handling, and beautiful output", - "main": "index.js", - "bin": { - "folder-sync": "index.js" - }, - "scripts": { - "start": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1", - "demo": "node index.js --help", - "precheck": "node scripts/pre-publish-check.js", - "test-package": "node scripts/test-package.js", - "release:patch": "node scripts/release.js patch", - "release:minor": "node scripts/release.js minor", - "release:major": "node scripts/release.js major", - "publish:check": "npm run precheck && npm run test-package", - "publish:safe": "npm run publish:check && npm publish", - "workflow:stats": "node scripts/workflow.js stats", - "workflow:social": "node scripts/workflow.js social", - "workflow:promotion": "node scripts/workflow.js promotion", - "workflow:commands": "node scripts/workflow.js commands" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/unique01082/folder-structure-sync.git" - }, - "keywords": [ - "folder", - "sync", - "directory", - "structure", - "cli", - "interactive", - "template", - "project-setup", - "development", - "automation", - "filesystem", - "folders", - "organize" - ], - "author": { - "name": "Bao LE", - "email": "bao.lq.it@gmail.com", - "url": "https://github.com/unique01082" - }, + "name": "rootline-workspace", + "version": "2.0.0", + "private": true, + "description": "Rootline by baole.space workspace orchestration", "license": "ISC", - "bugs": { - "url": "https://github.com/unique01082/folder-structure-sync/issues" - }, - "homepage": "https://github.com/unique01082/folder-structure-sync#readme", "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" + }, + "packageManager": "pnpm@10.33.0", + "scripts": { + "build": "pnpm -r --if-present run build", + "test": "pnpm --filter @rootline/contracts test", + "typecheck": "pnpm -r --if-present run typecheck" }, - "files": [ - "index.js", - "sync-config.json", - "README.md", - "LICENSE", - "CHANGELOG.md" - ], - "dependencies": { - "chalk": "^4.1.2", - "cli-progress": "^3.12.0", - "commander": "^14.0.0", - "inquirer": "^8.2.6" + "devDependencies": { + "typescript": "^5.9.2", + "vitest": "^3.2.4" } } diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..025ba03 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,15 @@ +{ + "name": "folder-structure-sync", + "version": "2.0.0", + "description": "Rootline by baole.space command-line interface", + "license": "ISC", + "type": "module", + "bin": { + "folder-sync": "./dist/index.js" + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..4131838 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rootline/contracts", + "version": "2.0.0", + "description": "Public domain and cloud contracts for Rootline by baole.space", + "license": "ISC", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run && pnpm run test:types", + "test:types": "tsc -p tsconfig.test.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..5b54b51 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,135 @@ +/** The only synchronization direction supported by Rootline v1. */ +export const SYNC_MODES = ["additive"] as const; + +export type SyncMode = (typeof SYNC_MODES)[number]; + +/** A complete local profile, including the absolute paths intentionally synced by cloud. */ +export interface SyncProfile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: readonly string[]; + createdAt: string; + updatedAt: string; + syncMode?: SyncMode; +} + +export const CLOUD_MUTATION_KINDS = ["upsert", "delete"] as const; + +export type CloudMutationKind = (typeof CLOUD_MUTATION_KINDS)[number]; + +export interface ProfileUpsertMutation { + mutationId: string; + kind: "upsert"; + profile: SyncProfile; + occurredAt: string; +} + +export interface ProfileDeleteMutation { + mutationId: string; + kind: "delete"; + profileId: string; + occurredAt: string; +} + +export type ProfileMutation = ProfileUpsertMutation | ProfileDeleteMutation; + +/** Client-to-server profile changes. The server assigns ordering by arrival. */ +export interface CloudSyncRequest { + deviceId: string; + epoch: string; + cursor?: string; + mutations: readonly ProfileMutation[]; +} + +export const PROFILE_RECORD_KINDS = ["profile", "tombstone"] as const; + +export type ProfileRecordKind = (typeof PROFILE_RECORD_KINDS)[number]; + +export interface SyncedProfileRecord { + kind: "profile"; + profile: SyncProfile; + revision: number; +} + +export interface ProfileTombstoneRecord { + kind: "tombstone"; + profileId: string; + deletedAt: string; + revision: number; +} + +/** A server projection is either a complete profile or a delete tombstone. */ +export type ProfileRecord = SyncedProfileRecord | ProfileTombstoneRecord; + +export interface CloudMutationReceipt { + mutationId: string; + revision: number; +} + +export interface CloudSyncResponse { + epoch: string; + cursor: string; + records: readonly ProfileRecord[]; + receipts: readonly CloudMutationReceipt[]; +} + +export interface AccountDataDeletionRequest { + epoch: string; +} + +export interface AccountDataDeletionResponse { + epoch: string; +} + +export const ROOTLINE_ERROR_CODES = { + CANCELLED: "CANCELLED", + CONFIG_INVALID: "CONFIG_INVALID", + INVALID_PATH: "INVALID_PATH", + PATH_OVERLAP: "PATH_OVERLAP", + SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND", + TARGET_NOT_FOUND: "TARGET_NOT_FOUND", + UNREADABLE_PATH: "UNREADABLE_PATH", + SYMLINK_SKIPPED: "SYMLINK_SKIPPED", + STALE_PLAN: "STALE_PLAN", + PARTIAL_FAILURE: "PARTIAL_FAILURE", + AUTH_REQUIRED: "AUTH_REQUIRED", + AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", + PROFILE_CONFLICT: "PROFILE_CONFLICT", + SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", + RATE_LIMITED: "RATE_LIMITED", + VALIDATION_FAILED: "VALIDATION_FAILED", + INTERNAL: "INTERNAL", +} as const; + +export type RootlineErrorCode = + (typeof ROOTLINE_ERROR_CODES)[keyof typeof ROOTLINE_ERROR_CODES]; + +export interface RootlineErrorInput { + code: RootlineErrorCode; + message: string; + retryable?: boolean; + details?: Record; +} + +/** A serializable error shape shared by CLI, desktop, and API boundaries. */ +export class RootlineError extends Error { + readonly code: RootlineErrorCode; + readonly retryable: boolean; + readonly details?: Record; + + constructor({ code, message, retryable = false, details }: RootlineErrorInput) { + super(message); + this.name = "RootlineError"; + this.code = code; + this.retryable = retryable; + if (details !== undefined) { + this.details = details; + } + } +} + +export function createRootlineError(input: RootlineErrorInput): RootlineError { + return new RootlineError(input); +} diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts new file mode 100644 index 0000000..faddd09 --- /dev/null +++ b/packages/contracts/test/contracts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import * as contracts from "../src/index.js"; + +describe("Rootline contracts", () => { + it("exposes stable shared error codes and structured errors", () => { + expect(contracts.ROOTLINE_ERROR_CODES).toEqual({ + CANCELLED: "CANCELLED", + CONFIG_INVALID: "CONFIG_INVALID", + INVALID_PATH: "INVALID_PATH", + PATH_OVERLAP: "PATH_OVERLAP", + SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND", + TARGET_NOT_FOUND: "TARGET_NOT_FOUND", + UNREADABLE_PATH: "UNREADABLE_PATH", + SYMLINK_SKIPPED: "SYMLINK_SKIPPED", + STALE_PLAN: "STALE_PLAN", + PARTIAL_FAILURE: "PARTIAL_FAILURE", + AUTH_REQUIRED: "AUTH_REQUIRED", + AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", + PROFILE_CONFLICT: "PROFILE_CONFLICT", + SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", + RATE_LIMITED: "RATE_LIMITED", + VALIDATION_FAILED: "VALIDATION_FAILED", + INTERNAL: "INTERNAL", + }); + + const error = contracts.createRootlineError({ + code: contracts.ROOTLINE_ERROR_CODES.PATH_OVERLAP, + message: "Source and target overlap.", + details: { sourcePath: "/source", targetPath: "/source/target" }, + }); + + expect(error).toBeInstanceOf(contracts.RootlineError); + expect(error).toMatchObject({ + code: "PATH_OVERLAP", + message: "Source and target overlap.", + retryable: false, + details: { sourcePath: "/source", targetPath: "/source/target" }, + }); + }); + + it("uses explicit discriminants for additive profiles and cloud mutations", () => { + expect(contracts.SYNC_MODES).toEqual(["additive"]); + expect(contracts.CLOUD_MUTATION_KINDS).toEqual(["upsert", "delete"]); + expect(contracts.PROFILE_RECORD_KINDS).toEqual(["profile", "tombstone"]); + }); +}); diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts new file mode 100644 index 0000000..a002bbb --- /dev/null +++ b/packages/contracts/test/contracts.types.test.ts @@ -0,0 +1,51 @@ +import { expectTypeOf, test } from "vitest"; + +import type { + CloudSyncRequest, + ProfileRecord, + RootlineErrorCode, + SyncProfile, +} from "../src/index.js"; + +test("public domain and cloud contracts retain their transport shapes", () => { + expectTypeOf().toMatchTypeOf<{ + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: readonly string[]; + createdAt: string; + updatedAt: string; + }>(); + + expectTypeOf().toMatchTypeOf<{ + deviceId: string; + epoch: string; + mutations: readonly unknown[]; + }>(); + + expectTypeOf().toMatchTypeOf< + | { kind: "profile"; profile: SyncProfile; revision: number } + | { kind: "tombstone"; profileId: string; deletedAt: string; revision: number } + >(); + + expectTypeOf().toEqualTypeOf< + | "CANCELLED" + | "CONFIG_INVALID" + | "INVALID_PATH" + | "PATH_OVERLAP" + | "SOURCE_NOT_FOUND" + | "TARGET_NOT_FOUND" + | "UNREADABLE_PATH" + | "SYMLINK_SKIPPED" + | "STALE_PLAN" + | "PARTIAL_FAILURE" + | "AUTH_REQUIRED" + | "AUTH_CALLBACK_INVALID" + | "PROFILE_CONFLICT" + | "SYNC_EPOCH_RESET_REQUIRED" + | "RATE_LIMITED" + | "VALIDATION_FAILED" + | "INTERNAL" + >(); +}); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/contracts/tsconfig.test.json b/packages/contracts/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/packages/contracts/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/contracts/vitest.config.ts b/packages/contracts/vitest.config.ts new file mode 100644 index 0000000..8b5840a --- /dev/null +++ b/packages/contracts/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..b1610d2 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@rootline/core", + "version": "2.0.0", + "private": true, + "description": "Rootline environment-independent synchronization core", + "license": "ISC", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..60b4a51 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1027 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7 + + apps/api: {} + + apps/desktop: {} + + packages/cli: {} + + packages/contracts: {} + + packages/core: {} + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6)': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6 + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6: + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.7: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..e367d75 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true + } +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..44729b8 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,6 @@ +import { defineWorkspace } from "vitest/config"; + +export default defineWorkspace([ + "packages/*/vitest.config.ts", + "apps/*/vitest.config.ts", +]); From b3fdb0ee3c47c9505f49a27181da02a79254fa45 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:32:08 +0700 Subject: [PATCH 04/26] feat: implement Rootline core and CLI v2 --- package.json | 1 + packages/cli/package.json | 5 + packages/cli/src/index.ts | 205 +++++++++++++++- packages/cli/src/node-adapter.ts | 296 ++++++++++++++++++++++++ packages/cli/test/cli.test.ts | 121 ++++++++++ packages/cli/test/node-adapter.test.ts | 103 +++++++++ packages/cli/test/package-smoke.test.ts | 50 ++++ packages/cli/tsconfig.json | 3 +- packages/cli/vitest.config.ts | 13 ++ packages/core/package.json | 12 + packages/core/src/index.ts | 179 +++++++++++++- packages/core/test/core.test.ts | 58 +++++ packages/core/tsconfig.test.json | 8 + packages/core/vitest.config.ts | 12 + pnpm-lock.yaml | 53 ++++- 15 files changed, 1104 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/node-adapter.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/test/node-adapter.test.ts create mode 100644 packages/cli/test/package-smoke.test.ts create mode 100644 packages/cli/vitest.config.ts create mode 100644 packages/core/test/core.test.ts create mode 100644 packages/core/tsconfig.test.json create mode 100644 packages/core/vitest.config.ts diff --git a/package.json b/package.json index 976a6bd..c4bbb0b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "typecheck": "pnpm -r --if-present run typecheck" }, "devDependencies": { + "@types/node": "^24.0.0", "typescript": "^5.9.2", "vitest": "^3.2.4" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 025ba03..026b3dd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,6 +10,11 @@ "files": ["dist"], "scripts": { "build": "tsc -p tsconfig.json", + "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@rootline/contracts": "workspace:*", + "@rootline/core": "workspace:*" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cb0ff5c..8dfb8c0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1 +1,204 @@ -export {}; +#!/usr/bin/env node + +import { readFileSync, realpathSync } from "node:fs"; +import { createInterface } from "node:readline/promises"; +import { resolve } from "node:path"; + +import { + ROOTLINE_ERROR_CODES, + RootlineError, + createRootlineError, + createSnapshot, + createSyncPlan, + validateRootRelationship, +} from "@rootline/core"; +import { NodeFileSystemAdapter, resolveConfig, type ApplyResult } from "./node-adapter.js"; + +export const EXIT_CODES = Object.freeze({ SUCCESS: 0, FAILURE: 1, VALIDATION: 2, PARTIAL: 3, CANCELLED: 4 }); + +interface CliOptions { + readonly source?: string | undefined; + readonly target?: string | undefined; + readonly dryRun: boolean; + readonly verbose: boolean; + readonly auto: boolean; + readonly json: boolean; + readonly configPath?: string | undefined; + readonly help: boolean; + readonly version: boolean; +} + +export interface CliOutput { + source?: { readonly entries: number; readonly skippedSymlinks: readonly string[] }; + target?: { readonly status: string; readonly entries?: number }; + plan?: { readonly fingerprint: string; readonly missing: readonly string[] }; + directories?: ApplyResult["directories"]; + cancelled?: boolean; + error?: { readonly code: string; readonly message: string }; +} + +function version(): string { + const packagePath = new URL("../package.json", import.meta.url); + return (JSON.parse(readFileSync(packagePath, "utf8")) as { version: string }).version; +} + +function usage(): string { + return [ + "Usage: folder-sync [options]", + "", + "Options:", + " -d, --dry-run Preview folders without creating them", + " -v, --verbose Include scan details in text output", + " -a, --auto Create every missing folder without prompts", + " --config Read exclusions from this JSON file", + " --json Emit one JSON document and never prompt", + " --version Print the package version", + ].join("\n"); +} + +function parseArguments(arguments_: readonly string[]): CliOptions { + const positional: string[] = []; + let dryRun = false; + let verbose = false; + let auto = false; + let json = false; + let configPath: string | undefined; + let help = false; + let showVersion = false; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]!; + if (argument === "-d" || argument === "--dry-run") dryRun = true; + else if (argument === "-v" || argument === "--verbose") verbose = true; + else if (argument === "-a" || argument === "--auto") auto = true; + else if (argument === "--json") json = true; + else if (argument === "-h" || argument === "--help") help = true; + else if (argument === "--version") showVersion = true; + else if (argument === "--config") { + const value = arguments_[index + 1]; + if (!value) throw configArgumentError(); + configPath = value; + index += 1; + } else if (argument.startsWith("-")) { + throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: `Unknown option: ${argument}` }); + } else { + positional.push(argument); + } + } + if (!help && !showVersion && positional.length !== 2) { + throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: "Source and target arguments are required." }); + } + return { source: positional[0], target: positional[1], dryRun, verbose, auto, json, configPath, help, version: showVersion }; +} + +function configArgumentError(): RootlineError { + return createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: "--config requires a path." }); +} + +function exitCodeFor(error: unknown): number { + if (!(error instanceof RootlineError)) return EXIT_CODES.FAILURE; + if (error.code === ROOTLINE_ERROR_CODES.PARTIAL_FAILURE) return EXIT_CODES.PARTIAL; + if (error.code === ROOTLINE_ERROR_CODES.CANCELLED) return EXIT_CODES.CANCELLED; + if ( + error.code === ROOTLINE_ERROR_CODES.CONFIG_INVALID || + error.code === ROOTLINE_ERROR_CODES.INVALID_PATH || + error.code === ROOTLINE_ERROR_CODES.PATH_OVERLAP || + error.code === ROOTLINE_ERROR_CODES.SOURCE_NOT_FOUND || + error.code === ROOTLINE_ERROR_CODES.TARGET_NOT_FOUND || + error.code === ROOTLINE_ERROR_CODES.UNREADABLE_PATH || + error.code === ROOTLINE_ERROR_CODES.STALE_PLAN + ) return EXIT_CODES.VALIDATION; + return EXIT_CODES.FAILURE; +} + +async function confirm(message: string): Promise { + if (!process.stdin.isTTY) { + throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CANCELLED, message: "Interactive confirmation requires a terminal." }); + } + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await readline.question(`${message} [Y/n] `)).trim().toLocaleLowerCase() !== "n"; + } finally { + readline.close(); + } +} + +export async function run(arguments_: readonly string[], cwd = process.cwd()): Promise<{ output: CliOutput; exitCode: number; text?: string }> { + let options: CliOptions; + try { + options = parseArguments(arguments_); + if (options.help) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: usage() }; + if (options.version) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: version() }; + + const sourcePath = resolve(cwd, options.source!); + const targetPath = resolve(cwd, options.target!); + const config = await resolveConfig({ cwd, explicitPath: options.configPath }); + validateRootRelationship(sourcePath, targetPath, config.targetCaseSensitive); + const adapter = new NodeFileSystemAdapter(); + const source = await adapter.scanDirectories(sourcePath, config.exclusions, "source"); + let targetStatus = await adapter.ensureTarget(targetPath, options.dryRun || !options.auto); + if (targetStatus === "would-create" && !options.dryRun) { + if (options.json) { + return { output: { source: { entries: source.snapshot.entries.length, skippedSymlinks: source.skippedSymlinks }, target: { status: "would-create" } }, exitCode: EXIT_CODES.SUCCESS }; + } + if (!(await confirm("Create the missing target directory?"))) { + return { output: { cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + } + targetStatus = await adapter.ensureTarget(targetPath); + } + const target = targetStatus === "would-create" + ? { snapshot: createSnapshot([]), skippedSymlinks: [] } + : await adapter.scanDirectories(targetPath, config.exclusions, "target"); + const plan = createSyncPlan(source.snapshot, target.snapshot, config.targetCaseSensitive); + const output: CliOutput = { + source: { entries: source.snapshot.entries.length, skippedSymlinks: source.skippedSymlinks }, + target: { status: targetStatus, entries: target.snapshot.entries.length }, + plan: { fingerprint: plan.fingerprint, missing: plan.missing }, + }; + if (plan.missing.length === 0 || options.dryRun) { + if (options.dryRun && targetStatus !== "would-create") { + output.directories = plan.missing.map((relativePath) => ({ relativePath, status: "would-create" })); + } + return { output, exitCode: EXIT_CODES.SUCCESS }; + } + if (!options.auto) { + if (options.json || !(await confirm(`Create ${plan.missing.length} missing folder(s)?`))) { + return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + } + } + const result = await adapter.applyDirectories(targetPath, source.snapshot, plan, { exclusions: config.exclusions }); + output.directories = result.directories; + if (result.directories.some((directory) => directory.status === "failed")) { + output.error = { code: ROOTLINE_ERROR_CODES.PARTIAL_FAILURE, message: "Some directories could not be created." }; + return { output, exitCode: EXIT_CODES.PARTIAL }; + } + return { output, exitCode: EXIT_CODES.SUCCESS }; + } catch (error: unknown) { + const rootlineError = error instanceof RootlineError + ? error + : createRootlineError({ code: ROOTLINE_ERROR_CODES.INTERNAL, message: error instanceof Error ? error.message : "Unexpected failure." }); + return { output: { error: { code: rootlineError.code, message: rootlineError.message } }, exitCode: exitCodeFor(rootlineError) }; + } +} + +function printResult(result: { output: CliOutput; exitCode: number; text?: string }, json: boolean): void { + if (json) { + process.stdout.write(`${JSON.stringify(result.output)}\n`); + return; + } + if (result.text) { + process.stdout.write(`${result.text}\n`); + } else if (result.output.error) { + process.stderr.write(`Error [${result.output.error.code}]: ${result.output.error.message}\n`); + } else { + process.stdout.write(`${JSON.stringify(result.output, null, 2)}\n`); + } +} + +const invokedAsCommand = process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url)); +if (invokedAsCommand) { + const json = process.argv.includes("--json"); + run(process.argv.slice(2)).then((result) => { + printResult(result, json); + process.exitCode = result.exitCode; + }); +} diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts new file mode 100644 index 0000000..f33b8c1 --- /dev/null +++ b/packages/cli/src/node-adapter.ts @@ -0,0 +1,296 @@ +import { promises as fs } from "node:fs"; +import { join, resolve } from "node:path"; + +import { + ROOTLINE_ERROR_CODES, + assertPlanFresh, + createRootlineError, + createSnapshot, + matchesExclusion, + selectPlanSubtree, + throwIfCancelled, + type CancellationSignalLike, + type DirectorySnapshot, + type SyncPlan, +} from "@rootline/core"; + +export const DEFAULT_EXCLUSIONS = [ + ".git", + ".svn", + ".hg", + "node_modules", + ".npm", + ".yarn", + "bower_components", + ".DS_Store", + "Thumbs.db", + ".vscode", + ".idea", + "*.tmp", + "*.temp", + "*.log", + ".cache", + "dist", + "build", + ".next", + ".nuxt", + "coverage", + ".nyc_output", +] as const; + +export interface ResolvedConfig { + readonly path?: string; + readonly exclusions: readonly string[]; + readonly targetCaseSensitive: boolean; +} + +export interface ResolveConfigOptions { + readonly cwd: string; + readonly explicitPath?: string | undefined; +} + +interface ConfigFile { + readonly defaultExclusions?: unknown; + readonly customExclusions?: unknown; + readonly targetCaseSensitive?: unknown; +} + +function configError(message: string, path: string, cause?: unknown): never { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, + message, + details: { path, ...(cause instanceof Error ? { cause: cause.message } : {}) }, + }); +} + +function requireStringArray(value: unknown, name: string, path: string): readonly string[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) { + return configError(`${name} must be an array of non-empty strings.`, path); + } + return value; +} + +/** Resolves only an explicit config or `sync-config.json` in the requested cwd. */ +export async function resolveConfig(options: ResolveConfigOptions): Promise { + const cwdConfig = join(options.cwd, "sync-config.json"); + const candidate = options.explicitPath ? resolve(options.cwd, options.explicitPath) : cwdConfig; + let raw: string; + try { + raw = await fs.readFile(candidate, "utf8"); + } catch (error: unknown) { + if (!options.explicitPath && isMissing(error)) { + return { exclusions: DEFAULT_EXCLUSIONS, targetCaseSensitive: defaultCaseSensitivity() }; + } + return configError("Unable to read the configuration file.", candidate, error); + } + + let parsed: ConfigFile; + try { + parsed = JSON.parse(raw) as ConfigFile; + } catch (error: unknown) { + return configError("The configuration file is not valid JSON.", candidate, error); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return configError("The configuration file must contain an object.", candidate); + } + if (parsed.targetCaseSensitive !== undefined && typeof parsed.targetCaseSensitive !== "boolean") { + return configError("targetCaseSensitive must be a boolean.", candidate); + } + const defaults = requireStringArray(parsed.defaultExclusions, "defaultExclusions", candidate); + const custom = requireStringArray(parsed.customExclusions, "customExclusions", candidate); + return { + path: candidate, + exclusions: [...(defaults.length > 0 ? defaults : DEFAULT_EXCLUSIONS), ...custom], + targetCaseSensitive: parsed.targetCaseSensitive ?? defaultCaseSensitivity(), + }; +} + +function defaultCaseSensitivity(): boolean { + return process.platform !== "win32" && process.platform !== "darwin"; +} + +function isMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +export interface DirectoryScan { + readonly snapshot: DirectorySnapshot; + readonly skippedSymlinks: readonly string[]; +} + +export type DirectoryResultStatus = "created" | "already-exists" | "would-create" | "failed"; + +export interface DirectoryResult { + readonly relativePath: string; + readonly status: DirectoryResultStatus; + readonly error?: string; +} + +export interface ApplyResult { + readonly directories: readonly DirectoryResult[]; +} + +export interface ApplyOptions { + readonly exclusions: readonly string[]; + readonly selected?: readonly string[]; + readonly dryRun?: boolean; + readonly signal?: CancellationSignalLike; +} + +export class NodeFileSystemAdapter { + async scanDirectories( + rootPath: string, + exclusions: readonly string[], + role: "source" | "target" = "source", + signal?: CancellationSignalLike, + ): Promise { + throwIfCancelled(signal); + const absoluteRoot = resolve(rootPath); + let rootStat; + try { + rootStat = await fs.lstat(absoluteRoot); + } catch (error: unknown) { + if (isMissing(error)) { + throw createRootlineError({ + code: role === "source" ? ROOTLINE_ERROR_CODES.SOURCE_NOT_FOUND : ROOTLINE_ERROR_CODES.TARGET_NOT_FOUND, + message: `${role === "source" ? "Source" : "Target"} directory does not exist.`, + details: { path: absoluteRoot }, + }); + } + throw unreadable(absoluteRoot, error); + } + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw unreadable(absoluteRoot); + } + + const entries: string[] = []; + const skippedSymlinks: string[] = []; + const visit = async (currentPath: string, relativePath: string): Promise => { + throwIfCancelled(signal); + if (await exists(join(currentPath, ".ignore"))) { + return; + } + let names: string[]; + try { + names = (await fs.readdir(currentPath)).sort((left, right) => left.localeCompare(right)); + } catch (error: unknown) { + throw unreadable(currentPath, error); + } + for (const name of names) { + throwIfCancelled(signal); + const childRelativePath = relativePath ? `${relativePath}/${name}` : name; + if (matchesExclusion(childRelativePath, exclusions)) { + continue; + } + const childPath = join(currentPath, name); + let stat; + try { + stat = await fs.lstat(childPath); + } catch (error: unknown) { + throw unreadable(childPath, error); + } + if (stat.isSymbolicLink()) { + skippedSymlinks.push(childRelativePath); + continue; + } + if (!stat.isDirectory()) { + continue; + } + entries.push(childRelativePath); + await visit(childPath, childRelativePath); + } + }; + + await visit(absoluteRoot, ""); + return { snapshot: createSnapshot(entries), skippedSymlinks: Object.freeze(skippedSymlinks) }; + } + + async ensureTarget(targetPath: string, dryRun = false): Promise<"created" | "already-exists" | "would-create"> { + const absoluteTarget = resolve(targetPath); + try { + const stat = await fs.lstat(absoluteTarget); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw unreadable(absoluteTarget); + } + return "already-exists"; + } catch (error: unknown) { + if (!isMissing(error)) { + throw error; + } + } + if (dryRun) { + return "would-create"; + } + try { + await fs.mkdir(absoluteTarget, { recursive: true }); + return "created"; + } catch (error: unknown) { + throw unreadable(absoluteTarget, error); + } + } + + async applyDirectories( + targetPath: string, + source: DirectorySnapshot, + plan: SyncPlan, + options: ApplyOptions, + ): Promise { + throwIfCancelled(options.signal); + const currentTarget = (await this.scanDirectories(targetPath, options.exclusions, "target", options.signal)).snapshot; + assertPlanFresh(plan, source, currentTarget); + const selected = options.selected ?? plan.missing; + const directories = selectPlanSubtree(plan, selected); + const result: DirectoryResult[] = []; + for (const relativePath of directories) { + throwIfCancelled(options.signal); + const fullPath = join(resolve(targetPath), ...relativePath.split("/")); + if (options.dryRun) { + result.push({ relativePath, status: "would-create" }); + continue; + } + try { + const existing = await fs.lstat(fullPath).catch((error: unknown) => (isMissing(error) ? undefined : Promise.reject(error))); + if (existing?.isDirectory()) { + result.push({ relativePath, status: "already-exists" }); + continue; + } + if (existing) { + result.push({ relativePath, status: "failed", error: "A non-directory already exists at this path." }); + continue; + } + await fs.mkdir(fullPath); + result.push({ relativePath, status: "created" }); + } catch (error: unknown) { + result.push({ + relativePath, + status: "failed", + error: error instanceof Error ? error.message : "Unable to create directory.", + }); + } + } + return { directories: Object.freeze(result) }; + } +} + +async function exists(path: string): Promise { + try { + await fs.lstat(path); + return true; + } catch (error: unknown) { + if (isMissing(error)) { + return false; + } + throw unreadable(path, error); + } +} + +function unreadable(path: string, cause?: unknown): ReturnType { + return createRootlineError({ + code: ROOTLINE_ERROR_CODES.UNREADABLE_PATH, + message: "A required path cannot be read as a directory.", + details: { path, ...(cause instanceof Error ? { cause: cause.message } : {}) }, + }); +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..14d574c --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,121 @@ +import { lstat, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +const directories: string[] = []; +const cliPath = join(process.cwd(), "dist", "index.js"); + +async function tempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "rootline-command-test-")); + directories.push(directory); + return directory; +} + +function run(...arguments_: string[]) { + return spawnSync(process.execPath, [cliPath, ...arguments_], { encoding: "utf8" }); +} + +beforeAll(() => { + const build = spawnSync("pnpm", ["--filter", "folder-structure-sync", "build"], { + cwd: join(process.cwd(), "../.."), + encoding: "utf8", + }); + if (build.status !== 0) { + throw new Error(build.stderr || build.stdout); + } +}); + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("folder-sync command", () => { + it("reads --version from the package metadata", () => { + const result = run("--version"); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("2.0.0"); + }); + + it("creates a missing target in auto JSON mode without prompting", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "src", "components"), { recursive: true }); + + const result = run(source, target, "--auto", "--json"); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); + }); + + it("keeps a missing target untouched in JSON mode without --auto", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "src"), { recursive: true }); + + const result = run(source, target, "--json"); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "would-create" } }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("emits JSON validation errors with their documented exit code", async () => { + const workspace = await tempDirectory(); + const target = join(workspace, "target"); + await mkdir(target); + + const result = run(join(workspace, "missing"), target, "--auto", "--json"); + + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "SOURCE_NOT_FOUND" } }); + }); + + it("rejects an invalid explicit config without writing prose to JSON output", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + const config = join(workspace, "invalid.json"); + await Promise.all([mkdir(source), mkdir(target), writeFile(config, "{not json")]); + + const result = run(source, target, "--auto", "--json", "--config", config); + + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + }); + + it("returns the validation exit code for overlapping roots", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + await mkdir(join(source, "nested"), { recursive: true }); + + const result = run(source, join(source, "nested"), "--auto", "--json"); + + expect(result.status).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "PATH_OVERLAP" } }); + }); + + it("returns the partial-failure exit code with per-directory statuses", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "blocked", "child"), { recursive: true }); + await mkdir(source, { recursive: true }); + await mkdir(target); + await writeFile(join(target, "blocked"), "not a directory"); + + const result = run(source, target, "--auto", "--json"); + const output = JSON.parse(result.stdout); + + expect(result.status).toBe(3); + expect(output).toMatchObject({ error: { code: "PARTIAL_FAILURE" } }); + expect(output.directories).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: "blocked", status: "failed" }), + ])); + }); +}); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts new file mode 100644 index 0000000..f0dfe45 --- /dev/null +++ b/packages/cli/test/node-adapter.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createSnapshot, createSyncPlan } from "@rootline/core"; +import { NodeFileSystemAdapter, resolveConfig } from "../src/node-adapter.js"; + +const temporaryDirectories: string[] = []; + +async function tempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "rootline-cli-test-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("Node filesystem adapter", () => { + it("resolves an explicit config ahead of the working-directory config", async () => { + const cwd = await tempDirectory(); + const explicit = join(cwd, "explicit.json"); + await writeFile(join(cwd, "sync-config.json"), JSON.stringify({ customExclusions: ["cwd-only"] })); + await writeFile(explicit, JSON.stringify({ customExclusions: ["explicit-only"] })); + + await expect(resolveConfig({ cwd, explicitPath: explicit })).resolves.toMatchObject({ + path: explicit, + exclusions: expect.arrayContaining(["explicit-only"]), + }); + await expect(resolveConfig({ cwd })).resolves.toMatchObject({ + path: join(cwd, "sync-config.json"), + exclusions: expect.arrayContaining(["cwd-only"]), + }); + }); + + it("skips symlinks and exact excluded segments while retaining .github", async () => { + const root = await tempDirectory(); + await mkdir(join(root, ".git", "objects"), { recursive: true }); + await mkdir(join(root, ".github", "workflows"), { recursive: true }); + await mkdir(join(root, "real"), { recursive: true }); + await symlink(join(root, "real"), join(root, "linked")); + + const scan = await new NodeFileSystemAdapter().scanDirectories(root, [".git"]); + + expect(scan.snapshot.entries).toEqual([".github", ".github/workflows", "real"]); + expect(scan.skippedSymlinks).toEqual(["linked"]); + }); + + it("honors legacy .ignore pruning and maps non-directory roots to unreadable errors", async () => { + const root = await tempDirectory(); + const file = join(root, "file"); + await mkdir(join(root, "ignored", "child"), { recursive: true }); + await writeFile(join(root, "ignored", ".ignore"), ""); + await writeFile(file, "not a directory"); + + await expect(new NodeFileSystemAdapter().scanDirectories(root, [])).resolves.toMatchObject({ + snapshot: { entries: ["ignored"] }, + }); + await expect(new NodeFileSystemAdapter().scanDirectories(file, [])).rejects.toMatchObject({ + code: "UNREADABLE_PATH", + }); + }); + + it("reports target mkdir states and observes cancellation at operation boundaries", async () => { + const target = join(await tempDirectory(), "target"); + const adapter = new NodeFileSystemAdapter(); + + await expect(adapter.ensureTarget(target, true)).resolves.toBe("would-create"); + await expect(adapter.ensureTarget(target)).resolves.toBe("created"); + await expect(adapter.ensureTarget(target)).resolves.toBe("already-exists"); + await expect(adapter.scanDirectories(target, [], "source", { aborted: true })).rejects.toMatchObject({ + code: "CANCELLED", + }); + }); + + it("revalidates stale plans before mkdir and reports per-directory results", async () => { + const target = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + const source = createSnapshot(["blocked", "blocked/child", "created"]); + const originalTarget = createSnapshot([]); + const plan = createSyncPlan(source, originalTarget); + await mkdir(join(target, "changed-after-plan")); + await writeFile(join(target, "blocked"), "not a directory"); + + await expect( + adapter.applyDirectories(target, source, plan, { exclusions: [] }), + ).rejects.toMatchObject({ code: "STALE_PLAN" }); + + const currentPlan = createSyncPlan(source, (await adapter.scanDirectories(target, [])).snapshot); + const result = await adapter.applyDirectories(target, source, currentPlan, { exclusions: [] }); + + expect(result.directories).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relativePath: "created", status: "created" }), + expect.objectContaining({ relativePath: "blocked/child", status: "failed" }), + ]), + ); + await expect(readFile(join(target, "created"), "utf8")).rejects.toThrow(); + }); +}); diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts new file mode 100644 index 0000000..a3e0a4b --- /dev/null +++ b/packages/cli/test/package-smoke.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +const workspace = join(process.cwd(), "../.."); +const scratch = await mkdtemp(join(tmpdir(), "rootline-package-smoke-")); + +function execute(command: string, arguments_: string[], cwd = workspace) { + const result = spawnSync(command, arguments_, { + cwd, + encoding: "utf8", + env: { ...process.env, npm_config_cache: join(scratch, "npm-cache") }, + }); + if (result.status !== 0) { + throw new Error(`${command} ${arguments_.join(" ")} failed:\n${result.stderr || result.stdout}`); + } + return result; +} + +afterAll(async () => { + await rm(scratch, { recursive: true, force: true }); +}); + +describe("published CLI package", () => { + it("installs from packed workspace tarballs and runs the folder-sync binary", async () => { + execute("pnpm", ["--filter", "@rootline/contracts", "build"]); + execute("pnpm", ["--filter", "@rootline/core", "build"]); + execute("pnpm", ["--filter", "folder-structure-sync", "build"]); + execute("pnpm", ["--filter", "@rootline/contracts", "pack", "--pack-destination", scratch]); + execute("pnpm", ["--filter", "@rootline/core", "pack", "--pack-destination", scratch]); + execute("pnpm", ["--filter", "folder-structure-sync", "pack", "--pack-destination", scratch]); + + const install = join(scratch, "install"); + const source = join(scratch, "source"); + const target = join(scratch, "target"); + await Promise.all([mkdir(install), mkdir(join(source, "nested"), { recursive: true })]); + const tarballs = [ + join(scratch, "rootline-contracts-2.0.0.tgz"), + join(scratch, "rootline-core-2.0.0.tgz"), + join(scratch, "folder-structure-sync-2.0.0.tgz"), + ]; + execute("npm", ["install", "--ignore-scripts", ...tarballs], install); + + const result = execute(join(install, "node_modules", ".bin", "folder-sync"), [source, target, "--auto", "--json"], install); + expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); + }, 30_000); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 5285d28..bb07ae1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..4a998f3 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, + resolve: { + alias: { + "@rootline/contracts": new URL("../contracts/src/index.ts", import.meta.url).pathname, + "@rootline/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index b1610d2..23e2062 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,8 +5,20 @@ "description": "Rootline environment-independent synchronization core", "license": "ISC", "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], "scripts": { "build": "tsc -p tsconfig.json", + "test": "vitest run && pnpm run test:types", + "test:types": "tsc -p tsconfig.test.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@rootline/contracts": "workspace:*" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cb0ff5c..97e8788 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,178 @@ -export {}; +import { + ROOTLINE_ERROR_CODES, + RootlineError, + createRootlineError, +} from "@rootline/contracts"; + +export { ROOTLINE_ERROR_CODES, RootlineError, createRootlineError }; + +export interface DirectorySnapshot { + readonly entries: readonly string[]; + readonly fingerprint: string; +} + +export interface SyncPlan { + readonly sourceFingerprint: string; + readonly targetFingerprint: string; + readonly missing: readonly string[]; + readonly fingerprint: string; +} + +export interface CancellationSignalLike { + readonly aborted: boolean; +} + +function invalidPath(message: string, value: string): never { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message, + details: { path: value }, + }); +} + +/** Converts a relative directory name to the portable format used by snapshots. */ +export function normalizeRelativePath(value: string): string { + const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/"); + if (normalized === "" || normalized === ".") { + return ""; + } + if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) { + return invalidPath("A relative path must not be absolute.", value); + } + + const parts = normalized.split("/"); + if (parts.some((part) => part === "" || part === "." || part === "..")) { + return invalidPath("A relative path must not traverse outside its root.", value); + } + return parts.join("/"); +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, "[^/]*"); + return new RegExp(`^${escaped}$`); +} + +/** Matches complete path segments, so `.git` never also excludes `.github`. */ +export function matchesExclusion(relativePath: string, patterns: readonly string[]): boolean { + const path = normalizeRelativePath(relativePath); + if (!path) { + return false; + } + const segments = path.split("/"); + return patterns.some((rawPattern) => { + const pattern = normalizeRelativePath(rawPattern); + if (!pattern) { + return false; + } + if (pattern.includes("/")) { + return globToRegExp(pattern).test(path); + } + const matcher = globToRegExp(pattern); + return segments.some((segment) => matcher.test(segment)); + }); +} + +/** Stable, dependency-free fingerprint for an already sorted sequence of paths. */ +export function fingerprint(entries: readonly string[]): string { + let hash = 0x811c9dc5; + for (const entry of entries) { + for (let index = 0; index < entry.length; index += 1) { + hash ^= entry.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + hash ^= 10; + hash = Math.imul(hash, 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +export function createSnapshot(entries: readonly string[]): DirectorySnapshot { + const normalized = [...new Set(entries.map(normalizeRelativePath).filter(Boolean))].sort((left, right) => + left.localeCompare(right), + ); + return Object.freeze({ entries: Object.freeze(normalized), fingerprint: fingerprint(normalized) }); +} + +export function createSyncPlan( + source: DirectorySnapshot, + target: DirectorySnapshot, + targetCaseSensitive = true, +): SyncPlan { + const comparable = (entry: string) => targetCaseSensitive ? entry : entry.toLocaleLowerCase(); + const targetEntries = new Set(target.entries.map(comparable)); + const missing = source.entries.filter((entry) => !targetEntries.has(comparable(entry))); + const planEntries = [source.fingerprint, target.fingerprint, ...missing]; + return Object.freeze({ + sourceFingerprint: source.fingerprint, + targetFingerprint: target.fingerprint, + missing: Object.freeze(missing), + fingerprint: fingerprint(planEntries), + }); +} + +/** Expands a requested subtree with only the parents that are also missing. */ +export function selectPlanSubtree(plan: SyncPlan, requested: readonly string[]): string[] { + const missing = new Set(plan.missing); + const selected = new Set(); + for (const rawPath of requested) { + const path = normalizeRelativePath(rawPath); + if (!missing.has(path)) { + continue; + } + const parts = path.split("/"); + for (let depth = 1; depth <= parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (missing.has(parent)) { + selected.add(parent); + } + } + } + return [...selected].sort((left, right) => { + const depth = left.split("/").length - right.split("/").length; + return depth === 0 ? left.localeCompare(right) : depth; + }); +} + +function normalizeRootPath(value: string, caseSensitive: boolean): string { + const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, ""); + return caseSensitive ? normalized : normalized.toLocaleLowerCase(); +} + +/** Rejects equal, ancestor, and descendant roots before any filesystem mutation. */ +export function validateRootRelationship(sourcePath: string, targetPath: string, caseSensitive: boolean): void { + const source = normalizeRootPath(sourcePath, caseSensitive); + const target = normalizeRootPath(targetPath, caseSensitive); + if (!source || !target) { + invalidPath("A synchronization root must not be empty.", !source ? sourcePath : targetPath); + } + if (source === target || source.startsWith(`${target}/`) || target.startsWith(`${source}/`)) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.PATH_OVERLAP, + message: "Source and target roots must not overlap.", + details: { sourcePath, targetPath }, + }); + } +} + +export function assertPlanFresh( + plan: SyncPlan, + source: DirectorySnapshot, + target: DirectorySnapshot, +): void { + if (plan.sourceFingerprint !== source.fingerprint || plan.targetFingerprint !== target.fingerprint) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.STALE_PLAN, + message: "The filesystem changed after this plan was created.", + details: { expectedPlan: plan.fingerprint }, + }); + } +} + +export function throwIfCancelled(signal?: CancellationSignalLike): void { + if (signal?.aborted) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.CANCELLED, + message: "The synchronization was cancelled.", + }); + } +} diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts new file mode 100644 index 0000000..0e93d63 --- /dev/null +++ b/packages/core/test/core.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + assertPlanFresh, + createSnapshot, + createSyncPlan, + matchesExclusion, + normalizeRelativePath, + selectPlanSubtree, + throwIfCancelled, + validateRootRelationship, +} from "../src/index.js"; + +describe("Rootline synchronization core", () => { + it("normalizes portable relative paths and rejects traversal", () => { + expect(normalizeRelativePath("src\\components//ui")).toBe("src/components/ui"); + expect(() => normalizeRelativePath("../outside")).toThrow("relative path"); + }); + + it("excludes an exact path segment without excluding similarly named directories", () => { + expect(matchesExclusion(".git/hooks", [".git"])).toBe(true); + expect(matchesExclusion(".github/workflows", [".git"])).toBe(false); + expect(matchesExclusion("build/cache", ["build"])).toBe(true); + expect(matchesExclusion("notes/error.log", ["*.log"])).toBe(true); + }); + + it("creates deterministic snapshots and dependency-complete subtree selections", () => { + const source = createSnapshot(["app/routes", "app", "docs", "app/routes/admin"]); + const target = createSnapshot(["docs"]); + const plan = createSyncPlan(source, target); + + expect(source.entries).toEqual(["app", "app/routes", "app/routes/admin", "docs"]); + expect(plan.missing).toEqual(["app", "app/routes", "app/routes/admin"]); + expect(selectPlanSubtree(plan, ["app/routes/admin"])).toEqual([ + "app", + "app/routes", + "app/routes/admin", + ]); + expect(createSnapshot([...source.entries].reverse()).fingerprint).toBe(source.fingerprint); + }); + + it("rejects equal or overlapping synchronization roots", () => { + expect(() => validateRootRelationship("/work/source", "/work/source/nested", true)).toThrow( + "overlap", + ); + expect(() => validateRootRelationship("C:\\Source", "c:/source", false)).toThrow("overlap"); + }); + + it("uses the target case policy and rejects stale or cancelled work", () => { + const source = createSnapshot(["Components"]); + const target = createSnapshot(["components"]); + const plan = createSyncPlan(source, createSnapshot([])); + + expect(createSyncPlan(source, target, false).missing).toEqual([]); + expect(() => assertPlanFresh(plan, source, target)).toThrow("changed"); + expect(() => throwIfCancelled({ aborted: true })).toThrow("cancelled"); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..45bbe8f --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..9b3c242 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, + resolve: { + alias: { + "@rootline/contracts": new URL("../contracts/src/index.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b4a51..bbfd06d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,22 +8,36 @@ importers: .: devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 typescript: specifier: ^5.9.2 version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7 + version: 3.2.7(@types/node@24.13.3) apps/api: {} apps/desktop: {} - packages/cli: {} + packages/cli: + dependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../contracts + '@rootline/core': + specifier: workspace:* + version: link:../core packages/contracts: {} - packages/core: {} + packages/core: + dependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../contracts packages: @@ -340,6 +354,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -510,6 +527,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -757,6 +777,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -765,13 +789,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6)': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) '@vitest/pretty-format@3.2.7': dependencies: @@ -950,13 +974,15 @@ snapshots: typescript@5.9.3: {} - vite-node@3.2.4: + undici-types@7.18.2: {} + + vite-node@3.2.4(@types/node@24.13.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6 + vite: 7.3.6(@types/node@24.13.3) transitivePeerDependencies: - '@types/node' - jiti @@ -971,7 +997,7 @@ snapshots: - tsx - yaml - vite@7.3.6: + vite@7.3.6(@types/node@24.13.3): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -980,13 +1006,14 @@ snapshots: rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@3.2.7: + vitest@3.2.7(@types/node@24.13.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -1004,9 +1031,11 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6 - vite-node: 3.2.4 + vite: 7.3.6(@types/node@24.13.3) + vite-node: 3.2.4(@types/node@24.13.3) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 transitivePeerDependencies: - jiti - less From 28bdf0685f361abfb0ff04f50db7c0355a5f35e2 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:39:42 +0700 Subject: [PATCH 05/26] fix: harden Rootline CLI plan safety --- packages/cli/src/index.ts | 33 ++++++++++++++------- packages/cli/src/node-adapter.ts | 18 ++++++++---- packages/cli/test/cli.test.ts | 30 ++++++++++++++++++- packages/cli/test/node-adapter.test.ts | 40 ++++++++++++++++++++++++-- packages/core/src/index.ts | 28 ++++++++++++------ packages/core/test/core.test.ts | 5 ++++ 6 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8dfb8c0..1b64ff7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -122,7 +122,11 @@ async function confirm(message: string): Promise { } } -export async function run(arguments_: readonly string[], cwd = process.cwd()): Promise<{ output: CliOutput; exitCode: number; text?: string }> { +export async function run( + arguments_: readonly string[], + cwd = process.cwd(), + confirmOperation: (message: string) => Promise = confirm, +): Promise<{ output: CliOutput; exitCode: number; text?: string }> { let options: CliOptions; try { options = parseArguments(arguments_); @@ -136,12 +140,9 @@ export async function run(arguments_: readonly string[], cwd = process.cwd()): P const adapter = new NodeFileSystemAdapter(); const source = await adapter.scanDirectories(sourcePath, config.exclusions, "source"); let targetStatus = await adapter.ensureTarget(targetPath, options.dryRun || !options.auto); - if (targetStatus === "would-create" && !options.dryRun) { - if (options.json) { - return { output: { source: { entries: source.snapshot.entries.length, skippedSymlinks: source.skippedSymlinks }, target: { status: "would-create" } }, exitCode: EXIT_CODES.SUCCESS }; - } - if (!(await confirm("Create the missing target directory?"))) { - return { output: { cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; + if (targetStatus === "would-create" && !options.dryRun && !options.json) { + if (!(await confirmOperation("Create the missing target directory?"))) { + return { output: { cancelled: true }, exitCode: EXIT_CODES.CANCELLED }; } targetStatus = await adapter.ensureTarget(targetPath); } @@ -161,11 +162,17 @@ export async function run(arguments_: readonly string[], cwd = process.cwd()): P return { output, exitCode: EXIT_CODES.SUCCESS }; } if (!options.auto) { - if (options.json || !(await confirm(`Create ${plan.missing.length} missing folder(s)?`))) { + if (options.json) { return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; } + if (!(await confirmOperation(`Create ${plan.missing.length} missing folder(s)?`))) { + return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.CANCELLED }; + } } - const result = await adapter.applyDirectories(targetPath, source.snapshot, plan, { exclusions: config.exclusions }); + const result = await adapter.applyDirectories(targetPath, plan, { + exclusions: config.exclusions, + sourcePath, + }); output.directories = result.directories; if (result.directories.some((directory) => directory.status === "failed")) { output.error = { code: ROOTLINE_ERROR_CODES.PARTIAL_FAILURE, message: "Some directories could not be created." }; @@ -180,7 +187,7 @@ export async function run(arguments_: readonly string[], cwd = process.cwd()): P } } -function printResult(result: { output: CliOutput; exitCode: number; text?: string }, json: boolean): void { +function printResult(result: { output: CliOutput; exitCode: number; text?: string }, json: boolean, verbose: boolean): void { if (json) { process.stdout.write(`${JSON.stringify(result.output)}\n`); return; @@ -190,6 +197,9 @@ function printResult(result: { output: CliOutput; exitCode: number; text?: strin } else if (result.output.error) { process.stderr.write(`Error [${result.output.error.code}]: ${result.output.error.message}\n`); } else { + if (verbose && result.output.source && result.output.target) { + process.stdout.write(`Source entries: ${result.output.source.entries}\nTarget entries: ${result.output.target.entries ?? 0}\n`); + } process.stdout.write(`${JSON.stringify(result.output, null, 2)}\n`); } } @@ -197,8 +207,9 @@ function printResult(result: { output: CliOutput; exitCode: number; text?: strin const invokedAsCommand = process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(new URL(import.meta.url)); if (invokedAsCommand) { const json = process.argv.includes("--json"); + const verbose = process.argv.includes("--verbose") || process.argv.includes("-v"); run(process.argv.slice(2)).then((result) => { - printResult(result, json); + printResult(result, json, verbose); process.exitCode = result.exitCode; }); } diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts index f33b8c1..b4999ae 100644 --- a/packages/cli/src/node-adapter.ts +++ b/packages/cli/src/node-adapter.ts @@ -6,6 +6,7 @@ import { assertPlanFresh, createRootlineError, createSnapshot, + compareRelativePaths, matchesExclusion, selectPlanSubtree, throwIfCancelled, @@ -99,11 +100,13 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise 0 ? defaults : DEFAULT_EXCLUSIONS), ...custom], + exclusions: [...defaults, ...custom], targetCaseSensitive: parsed.targetCaseSensitive ?? defaultCaseSensitivity(), }; } @@ -135,6 +138,7 @@ export interface ApplyResult { export interface ApplyOptions { readonly exclusions: readonly string[]; + readonly sourcePath: string; readonly selected?: readonly string[]; readonly dryRun?: boolean; readonly signal?: CancellationSignalLike; @@ -175,7 +179,7 @@ export class NodeFileSystemAdapter { } let names: string[]; try { - names = (await fs.readdir(currentPath)).sort((left, right) => left.localeCompare(right)); + names = (await fs.readdir(currentPath)).sort(compareRelativePaths); } catch (error: unknown) { throw unreadable(currentPath, error); } @@ -234,13 +238,15 @@ export class NodeFileSystemAdapter { async applyDirectories( targetPath: string, - source: DirectorySnapshot, plan: SyncPlan, options: ApplyOptions, ): Promise { throwIfCancelled(options.signal); - const currentTarget = (await this.scanDirectories(targetPath, options.exclusions, "target", options.signal)).snapshot; - assertPlanFresh(plan, source, currentTarget); + const [currentSource, currentTarget] = await Promise.all([ + this.scanDirectories(options.sourcePath, options.exclusions, "source", options.signal), + this.scanDirectories(targetPath, options.exclusions, "target", options.signal), + ]); + assertPlanFresh(plan, currentSource.snapshot, currentTarget.snapshot); const selected = options.selected ?? plan.missing; const directories = selectPlanSubtree(plan, selected); const result: DirectoryResult[] = []; diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 14d574c..78e821f 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { run as runProgram } from "../src/index.js"; const directories: string[] = []; const cliPath = join(process.cwd(), "dist", "index.js"); @@ -61,7 +62,10 @@ describe("folder-sync command", () => { const result = run(source, target, "--json"); expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "would-create" } }); + expect(JSON.parse(result.stdout)).toMatchObject({ + target: { status: "would-create" }, + plan: { missing: ["src"] }, + }); await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); }); @@ -118,4 +122,28 @@ describe("folder-sync command", () => { expect.objectContaining({ relativePath: "blocked", status: "failed" }), ])); }); + + it("keeps the legacy verbose flag observable in text output", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "src"), { recursive: true }), mkdir(target)]); + + const result = run(source, target, "--auto", "--verbose"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Source entries: 1"); + }); + + it("uses the cancelled exit code when an interactive user declines", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "src"), { recursive: true }), mkdir(target)]); + + const result = await runProgram([source, target], workspace, async () => false); + + expect(result.exitCode).toBe(4); + expect(result.output).toMatchObject({ cancelled: true }); + }); }); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index f0dfe45..0e4044e 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -34,6 +34,9 @@ describe("Node filesystem adapter", () => { path: join(cwd, "sync-config.json"), exclusions: expect.arrayContaining(["cwd-only"]), }); + + await writeFile(explicit, JSON.stringify({ defaultExclusions: [], customExclusions: [] })); + await expect(resolveConfig({ cwd, explicitPath: explicit })).resolves.toMatchObject({ exclusions: [] }); }); it("skips symlinks and exact excluded segments while retaining .github", async () => { @@ -64,6 +67,21 @@ describe("Node filesystem adapter", () => { }); }); + it("reports an unreadable child directory instead of silently creating an incomplete snapshot", async () => { + const root = await tempDirectory(); + const locked = join(root, "locked"); + await mkdir(locked); + await chmod(locked, 0o000); + + try { + await expect(new NodeFileSystemAdapter().scanDirectories(root, [])).rejects.toMatchObject({ + code: "UNREADABLE_PATH", + }); + } finally { + await chmod(locked, 0o700); + } + }); + it("reports target mkdir states and observes cancellation at operation boundaries", async () => { const target = join(await tempDirectory(), "target"); const adapter = new NodeFileSystemAdapter(); @@ -78,19 +96,21 @@ describe("Node filesystem adapter", () => { it("revalidates stale plans before mkdir and reports per-directory results", async () => { const target = await tempDirectory(); + const sourceRoot = await tempDirectory(); const adapter = new NodeFileSystemAdapter(); const source = createSnapshot(["blocked", "blocked/child", "created"]); const originalTarget = createSnapshot([]); const plan = createSyncPlan(source, originalTarget); + await Promise.all(source.entries.map((entry) => mkdir(join(sourceRoot, entry), { recursive: true }))); await mkdir(join(target, "changed-after-plan")); await writeFile(join(target, "blocked"), "not a directory"); await expect( - adapter.applyDirectories(target, source, plan, { exclusions: [] }), + adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot }), ).rejects.toMatchObject({ code: "STALE_PLAN" }); const currentPlan = createSyncPlan(source, (await adapter.scanDirectories(target, [])).snapshot); - const result = await adapter.applyDirectories(target, source, currentPlan, { exclusions: [] }); + const result = await adapter.applyDirectories(target, currentPlan, { exclusions: [], sourcePath: sourceRoot }); expect(result.directories).toEqual( expect.arrayContaining([ @@ -100,4 +120,18 @@ describe("Node filesystem adapter", () => { ); await expect(readFile(join(target, "created"), "utf8")).rejects.toThrow(); }); + + it("rejects a source tree that changed after planning", async () => { + const sourceRoot = await tempDirectory(); + const target = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + await mkdir(join(sourceRoot, "planned")); + const source = (await adapter.scanDirectories(sourceRoot, [])).snapshot; + const plan = createSyncPlan(source, createSnapshot([])); + await mkdir(join(sourceRoot, "added-after-plan")); + + await expect(adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot })).rejects.toMatchObject({ + code: "STALE_PLAN", + }); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 97e8788..c122c2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export interface DirectorySnapshot { export interface SyncPlan { readonly sourceFingerprint: string; readonly targetFingerprint: string; + readonly targetCaseSensitive: boolean; readonly missing: readonly string[]; readonly fingerprint: string; } @@ -32,7 +33,7 @@ function invalidPath(message: string, value: string): never { /** Converts a relative directory name to the portable format used by snapshots. */ export function normalizeRelativePath(value: string): string { - const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/"); + const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/"); if (normalized === "" || normalized === ".") { return ""; } @@ -86,10 +87,13 @@ export function fingerprint(entries: readonly string[]): string { return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`; } +/** Locale-independent ordering keeps plans and fingerprints portable across hosts. */ +export function compareRelativePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + export function createSnapshot(entries: readonly string[]): DirectorySnapshot { - const normalized = [...new Set(entries.map(normalizeRelativePath).filter(Boolean))].sort((left, right) => - left.localeCompare(right), - ); + const normalized = [...new Set(entries.map(normalizeRelativePath).filter(Boolean))].sort(compareRelativePaths); return Object.freeze({ entries: Object.freeze(normalized), fingerprint: fingerprint(normalized) }); } @@ -98,13 +102,14 @@ export function createSyncPlan( target: DirectorySnapshot, targetCaseSensitive = true, ): SyncPlan { - const comparable = (entry: string) => targetCaseSensitive ? entry : entry.toLocaleLowerCase(); + const comparable = (entry: string) => targetCaseSensitive ? entry : entry.toLowerCase(); const targetEntries = new Set(target.entries.map(comparable)); const missing = source.entries.filter((entry) => !targetEntries.has(comparable(entry))); - const planEntries = [source.fingerprint, target.fingerprint, ...missing]; + const planEntries = [source.fingerprint, target.fingerprint, targetCaseSensitive ? "case-sensitive" : "case-insensitive", ...missing]; return Object.freeze({ sourceFingerprint: source.fingerprint, targetFingerprint: target.fingerprint, + targetCaseSensitive, missing: Object.freeze(missing), fingerprint: fingerprint(planEntries), }); @@ -129,7 +134,7 @@ export function selectPlanSubtree(plan: SyncPlan, requested: readonly string[]): } return [...selected].sort((left, right) => { const depth = left.split("/").length - right.split("/").length; - return depth === 0 ? left.localeCompare(right) : depth; + return depth === 0 ? compareRelativePaths(left, right) : depth; }); } @@ -159,7 +164,14 @@ export function assertPlanFresh( source: DirectorySnapshot, target: DirectorySnapshot, ): void { - if (plan.sourceFingerprint !== source.fingerprint || plan.targetFingerprint !== target.fingerprint) { + const expected = createSyncPlan(source, target, plan.targetCaseSensitive); + if ( + plan.sourceFingerprint !== source.fingerprint || + plan.targetFingerprint !== target.fingerprint || + plan.fingerprint !== expected.fingerprint || + plan.missing.length !== expected.missing.length || + plan.missing.some((entry, index) => entry !== expected.missing[index]) + ) { throw createRootlineError({ code: ROOTLINE_ERROR_CODES.STALE_PLAN, message: "The filesystem changed after this plan was created.", diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 0e93d63..40f5728 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -14,6 +14,7 @@ import { describe("Rootline synchronization core", () => { it("normalizes portable relative paths and rejects traversal", () => { expect(normalizeRelativePath("src\\components//ui")).toBe("src/components/ui"); + expect(normalizeRelativePath(" docs ")).toBe(" docs "); expect(() => normalizeRelativePath("../outside")).toThrow("relative path"); }); @@ -37,6 +38,7 @@ describe("Rootline synchronization core", () => { "app/routes/admin", ]); expect(createSnapshot([...source.entries].reverse()).fingerprint).toBe(source.fingerprint); + expect(createSnapshot(["z", "ä", "a"]).entries).toEqual(["a", "z", "ä"]); }); it("rejects equal or overlapping synchronization roots", () => { @@ -54,5 +56,8 @@ describe("Rootline synchronization core", () => { expect(createSyncPlan(source, target, false).missing).toEqual([]); expect(() => assertPlanFresh(plan, source, target)).toThrow("changed"); expect(() => throwIfCancelled({ aborted: true })).toThrow("cancelled"); + + const forgedPlan = { ...plan, missing: ["outside"], fingerprint: plan.fingerprint }; + expect(() => assertPlanFresh(forgedPlan, source, createSnapshot([]))).toThrow("changed"); }); }); From 052f83bc969b95fb3850350e2e13a6714ac36e24 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:41:22 +0700 Subject: [PATCH 06/26] fix: make Rootline overlap checks locale independent --- packages/core/src/index.ts | 2 +- packages/core/test/core.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c122c2c..0b786ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,7 +140,7 @@ export function selectPlanSubtree(plan: SyncPlan, requested: readonly string[]): function normalizeRootPath(value: string, caseSensitive: boolean): string { const normalized = value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, ""); - return caseSensitive ? normalized : normalized.toLocaleLowerCase(); + return caseSensitive ? normalized : normalized.toLowerCase(); } /** Rejects equal, ancestor, and descendant roots before any filesystem mutation. */ diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 40f5728..7a26566 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -48,6 +48,18 @@ describe("Rootline synchronization core", () => { expect(() => validateRootRelationship("C:\\Source", "c:/source", false)).toThrow("overlap"); }); + it("does not depend on the host locale for case-insensitive overlap safety", () => { + const localeLowerCase = String.prototype.toLocaleLowerCase; + String.prototype.toLocaleLowerCase = function localeSensitiveLowerCase(): string { + return this.toString(); + }; + try { + expect(() => validateRootRelationship("/work/I", "/work/i", false)).toThrow("overlap"); + } finally { + String.prototype.toLocaleLowerCase = localeLowerCase; + } + }); + it("uses the target case policy and rejects stale or cancelled work", () => { const source = createSnapshot(["Components"]); const target = createSnapshot(["components"]); From 5eb0e6ffc3d2521c465a1e4f91c95a986547c1e4 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:50:02 +0700 Subject: [PATCH 07/26] fix: enforce CLI exits and canonical roots --- packages/cli/src/index.ts | 59 +++++++++++---------- packages/cli/src/node-adapter.ts | 72 +++++++++++++++++++++----- packages/cli/test/cli.test.ts | 52 +++++++++++++++---- packages/cli/test/node-adapter.test.ts | 21 +++++++- 4 files changed, 149 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1b64ff7..9292ad2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,7 +14,14 @@ import { } from "@rootline/core"; import { NodeFileSystemAdapter, resolveConfig, type ApplyResult } from "./node-adapter.js"; -export const EXIT_CODES = Object.freeze({ SUCCESS: 0, FAILURE: 1, VALIDATION: 2, PARTIAL: 3, CANCELLED: 4 }); +export const EXIT_CODES = Object.freeze({ SUCCESS: 0, FAILURE: 1, USAGE: 2 }); + +class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} interface CliOptions { readonly source?: string | undefined; @@ -75,39 +82,23 @@ function parseArguments(arguments_: readonly string[]): CliOptions { else if (argument === "--version") showVersion = true; else if (argument === "--config") { const value = arguments_[index + 1]; - if (!value) throw configArgumentError(); + if (!value || value.startsWith("-")) throw configArgumentError(); configPath = value; index += 1; } else if (argument.startsWith("-")) { - throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: `Unknown option: ${argument}` }); + throw new UsageError(`Unknown option: ${argument}`); } else { positional.push(argument); } } if (!help && !showVersion && positional.length !== 2) { - throw createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: "Source and target arguments are required." }); + throw new UsageError("Source and target arguments are required."); } return { source: positional[0], target: positional[1], dryRun, verbose, auto, json, configPath, help, version: showVersion }; } -function configArgumentError(): RootlineError { - return createRootlineError({ code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, message: "--config requires a path." }); -} - -function exitCodeFor(error: unknown): number { - if (!(error instanceof RootlineError)) return EXIT_CODES.FAILURE; - if (error.code === ROOTLINE_ERROR_CODES.PARTIAL_FAILURE) return EXIT_CODES.PARTIAL; - if (error.code === ROOTLINE_ERROR_CODES.CANCELLED) return EXIT_CODES.CANCELLED; - if ( - error.code === ROOTLINE_ERROR_CODES.CONFIG_INVALID || - error.code === ROOTLINE_ERROR_CODES.INVALID_PATH || - error.code === ROOTLINE_ERROR_CODES.PATH_OVERLAP || - error.code === ROOTLINE_ERROR_CODES.SOURCE_NOT_FOUND || - error.code === ROOTLINE_ERROR_CODES.TARGET_NOT_FOUND || - error.code === ROOTLINE_ERROR_CODES.UNREADABLE_PATH || - error.code === ROOTLINE_ERROR_CODES.STALE_PLAN - ) return EXIT_CODES.VALIDATION; - return EXIT_CODES.FAILURE; +function configArgumentError(): UsageError { + return new UsageError("--config requires a path."); } async function confirm(message: string): Promise { @@ -133,16 +124,18 @@ export async function run( if (options.help) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: usage() }; if (options.version) return { output: {}, exitCode: EXIT_CODES.SUCCESS, text: version() }; - const sourcePath = resolve(cwd, options.source!); - const targetPath = resolve(cwd, options.target!); + const adapter = new NodeFileSystemAdapter(); const config = await resolveConfig({ cwd, explicitPath: options.configPath }); + const { sourcePath, targetPath } = await adapter.validateRootPaths( + resolve(cwd, options.source!), + resolve(cwd, options.target!), + ); validateRootRelationship(sourcePath, targetPath, config.targetCaseSensitive); - const adapter = new NodeFileSystemAdapter(); const source = await adapter.scanDirectories(sourcePath, config.exclusions, "source"); let targetStatus = await adapter.ensureTarget(targetPath, options.dryRun || !options.auto); if (targetStatus === "would-create" && !options.dryRun && !options.json) { if (!(await confirmOperation("Create the missing target directory?"))) { - return { output: { cancelled: true }, exitCode: EXIT_CODES.CANCELLED }; + return { output: { cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; } targetStatus = await adapter.ensureTarget(targetPath); } @@ -166,7 +159,7 @@ export async function run( return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; } if (!(await confirmOperation(`Create ${plan.missing.length} missing folder(s)?`))) { - return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.CANCELLED }; + return { output: { ...output, cancelled: true }, exitCode: EXIT_CODES.SUCCESS }; } } const result = await adapter.applyDirectories(targetPath, plan, { @@ -176,14 +169,20 @@ export async function run( output.directories = result.directories; if (result.directories.some((directory) => directory.status === "failed")) { output.error = { code: ROOTLINE_ERROR_CODES.PARTIAL_FAILURE, message: "Some directories could not be created." }; - return { output, exitCode: EXIT_CODES.PARTIAL }; + return { output, exitCode: EXIT_CODES.FAILURE }; } return { output, exitCode: EXIT_CODES.SUCCESS }; } catch (error: unknown) { const rootlineError = error instanceof RootlineError ? error - : createRootlineError({ code: ROOTLINE_ERROR_CODES.INTERNAL, message: error instanceof Error ? error.message : "Unexpected failure." }); - return { output: { error: { code: rootlineError.code, message: rootlineError.message } }, exitCode: exitCodeFor(rootlineError) }; + : createRootlineError({ + code: ROOTLINE_ERROR_CODES.CONFIG_INVALID, + message: error instanceof Error ? error.message : "Unexpected failure.", + }); + return { + output: { error: { code: rootlineError.code, message: rootlineError.message } }, + exitCode: error instanceof UsageError ? EXIT_CODES.USAGE : EXIT_CODES.FAILURE, + }; } } diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts index b4999ae..3a2beea 100644 --- a/packages/cli/src/node-adapter.ts +++ b/packages/cli/src/node-adapter.ts @@ -1,5 +1,5 @@ import { promises as fs } from "node:fs"; -import { join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { ROOTLINE_ERROR_CODES, @@ -145,6 +145,14 @@ export interface ApplyOptions { } export class NodeFileSystemAdapter { + async validateRootPaths(sourcePath: string, targetPath: string): Promise<{ sourcePath: string; targetPath: string }> { + const [canonicalSource, canonicalTarget] = await Promise.all([ + this.canonicalizeExistingPrefix(sourcePath), + this.canonicalizeExistingPrefix(targetPath), + ]); + return { sourcePath: canonicalSource, targetPath: canonicalTarget }; + } + async scanDirectories( rootPath: string, exclusions: readonly string[], @@ -153,21 +161,22 @@ export class NodeFileSystemAdapter { ): Promise { throwIfCancelled(signal); const absoluteRoot = resolve(rootPath); + const canonicalRoot = await this.canonicalizeExistingPrefix(absoluteRoot); let rootStat; try { - rootStat = await fs.lstat(absoluteRoot); + rootStat = await fs.lstat(canonicalRoot); } catch (error: unknown) { if (isMissing(error)) { throw createRootlineError({ code: role === "source" ? ROOTLINE_ERROR_CODES.SOURCE_NOT_FOUND : ROOTLINE_ERROR_CODES.TARGET_NOT_FOUND, message: `${role === "source" ? "Source" : "Target"} directory does not exist.`, - details: { path: absoluteRoot }, + details: { path: canonicalRoot }, }); } - throw unreadable(absoluteRoot, error); + throw unreadable(canonicalRoot, error); } if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { - throw unreadable(absoluteRoot); + throw unreadable(canonicalRoot); } const entries: string[] = []; @@ -208,16 +217,17 @@ export class NodeFileSystemAdapter { } }; - await visit(absoluteRoot, ""); + await visit(canonicalRoot, ""); return { snapshot: createSnapshot(entries), skippedSymlinks: Object.freeze(skippedSymlinks) }; } async ensureTarget(targetPath: string, dryRun = false): Promise<"created" | "already-exists" | "would-create"> { const absoluteTarget = resolve(targetPath); + const canonicalTarget = await this.canonicalizeExistingPrefix(absoluteTarget); try { - const stat = await fs.lstat(absoluteTarget); + const stat = await fs.lstat(canonicalTarget); if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw unreadable(absoluteTarget); + throw unreadable(canonicalTarget); } return "already-exists"; } catch (error: unknown) { @@ -229,10 +239,10 @@ export class NodeFileSystemAdapter { return "would-create"; } try { - await fs.mkdir(absoluteTarget, { recursive: true }); + await fs.mkdir(canonicalTarget, { recursive: true }); return "created"; } catch (error: unknown) { - throw unreadable(absoluteTarget, error); + throw unreadable(canonicalTarget, error); } } @@ -242,9 +252,11 @@ export class NodeFileSystemAdapter { options: ApplyOptions, ): Promise { throwIfCancelled(options.signal); + const canonicalTarget = await this.canonicalizeExistingPrefix(targetPath); + const canonicalSource = await this.canonicalizeExistingPrefix(options.sourcePath); const [currentSource, currentTarget] = await Promise.all([ - this.scanDirectories(options.sourcePath, options.exclusions, "source", options.signal), - this.scanDirectories(targetPath, options.exclusions, "target", options.signal), + this.scanDirectories(canonicalSource, options.exclusions, "source", options.signal), + this.scanDirectories(canonicalTarget, options.exclusions, "target", options.signal), ]); assertPlanFresh(plan, currentSource.snapshot, currentTarget.snapshot); const selected = options.selected ?? plan.missing; @@ -252,12 +264,20 @@ export class NodeFileSystemAdapter { const result: DirectoryResult[] = []; for (const relativePath of directories) { throwIfCancelled(options.signal); - const fullPath = join(resolve(targetPath), ...relativePath.split("/")); + const fullPath = join(canonicalTarget, ...relativePath.split("/")); if (options.dryRun) { result.push({ relativePath, status: "would-create" }); continue; } try { + if ((await this.canonicalizeExistingPrefix(fullPath)) !== fullPath) { + result.push({ + relativePath, + status: "failed", + error: "A target path component traverses a symbolic link or junction.", + }); + continue; + } const existing = await fs.lstat(fullPath).catch((error: unknown) => (isMissing(error) ? undefined : Promise.reject(error))); if (existing?.isDirectory()) { result.push({ relativePath, status: "already-exists" }); @@ -279,6 +299,32 @@ export class NodeFileSystemAdapter { } return { directories: Object.freeze(result) }; } + + private async canonicalizeExistingPrefix(path: string): Promise { + let current = resolve(path); + const missing: string[] = []; + while (true) { + try { + await fs.lstat(current); + const canonical = await fs.realpath(current); + return join(canonical, ...missing); + } catch (error: unknown) { + if (!isMissing(error)) { + throw unreadable(current, error); + } + const parent = dirname(current); + if (parent === current) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message: "A synchronization root has no existing canonical ancestor.", + details: { path }, + }); + } + missing.unshift(basename(current)); + current = parent; + } + } + } } async function exists(path: string): Promise { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 78e821f..51d324f 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,4 +1,4 @@ -import { lstat, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -69,18 +69,18 @@ describe("folder-sync command", () => { await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("emits JSON validation errors with their documented exit code", async () => { + it("emits JSON validation errors with the filesystem-failure exit code", async () => { const workspace = await tempDirectory(); const target = join(workspace, "target"); await mkdir(target); const result = run(join(workspace, "missing"), target, "--auto", "--json"); - expect(result.status).toBe(2); + expect(result.status).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "SOURCE_NOT_FOUND" } }); }); - it("rejects an invalid explicit config without writing prose to JSON output", async () => { + it("rejects an invalid explicit config as a filesystem/config failure", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); const target = join(workspace, "target"); @@ -89,22 +89,22 @@ describe("folder-sync command", () => { const result = run(source, target, "--auto", "--json", "--config", config); - expect(result.status).toBe(2); + expect(result.status).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); }); - it("returns the validation exit code for overlapping roots", async () => { + it("returns the filesystem-failure exit code for overlapping roots", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); await mkdir(join(source, "nested"), { recursive: true }); const result = run(source, join(source, "nested"), "--auto", "--json"); - expect(result.status).toBe(2); + expect(result.status).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "PATH_OVERLAP" } }); }); - it("returns the partial-failure exit code with per-directory statuses", async () => { + it("returns the filesystem-failure exit code with partial mkdir statuses", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); const target = join(workspace, "target"); @@ -116,7 +116,7 @@ describe("folder-sync command", () => { const result = run(source, target, "--auto", "--json"); const output = JSON.parse(result.stdout); - expect(result.status).toBe(3); + expect(result.status).toBe(1); expect(output).toMatchObject({ error: { code: "PARTIAL_FAILURE" } }); expect(output.directories).toEqual(expect.arrayContaining([ expect.objectContaining({ relativePath: "blocked", status: "failed" }), @@ -135,7 +135,7 @@ describe("folder-sync command", () => { expect(result.stdout).toContain("Source entries: 1"); }); - it("uses the cancelled exit code when an interactive user declines", async () => { + it("treats an interactive user decline as a successful no-op", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); const target = join(workspace, "target"); @@ -143,7 +143,37 @@ describe("folder-sync command", () => { const result = await runProgram([source, target], workspace, async () => false); - expect(result.exitCode).toBe(4); + expect(result.exitCode).toBe(0); expect(result.output).toMatchObject({ cancelled: true }); }); + + it("uses the usage exit code only for argument parsing errors", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await Promise.all([mkdir(source), mkdir(target)]); + + const unknownFlag = run(source, target, "--not-a-flag", "--json"); + const missingConfigValue = run(source, target, "--config", "--json"); + + expect(unknownFlag.status).toBe(2); + expect(JSON.parse(unknownFlag.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + expect(missingConfigValue.status).toBe(2); + expect(JSON.parse(missingConfigValue.stdout)).toMatchObject({ error: { code: "CONFIG_INVALID" } }); + }); + + it("rejects a missing target whose existing ancestor is a source alias before mkdir", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const alias = join(workspace, "alias"); + const target = join(alias, "nested"); + await mkdir(join(source, "planned"), { recursive: true }); + await symlink(source, alias, "dir"); + + const result = run(source, target, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "PATH_OVERLAP" } }); + await expect(lstat(join(source, "nested"))).rejects.toMatchObject({ code: "ENOENT" }); + }); }); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index 0e4044e..223ce07 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -134,4 +134,23 @@ describe("Node filesystem adapter", () => { code: "STALE_PLAN", }); }); + + it("does not follow a target child symlink introduced after planning", async () => { + const sourceRoot = await tempDirectory(); + const target = await tempDirectory(); + const external = await tempDirectory(); + const adapter = new NodeFileSystemAdapter(); + await mkdir(join(sourceRoot, "parent", "child"), { recursive: true }); + const source = (await adapter.scanDirectories(sourceRoot, [])).snapshot; + const plan = createSyncPlan(source, createSnapshot([])); + await symlink(external, join(target, "parent"), "dir"); + + const result = await adapter.applyDirectories(target, plan, { exclusions: [], sourcePath: sourceRoot }); + + expect(result.directories).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: "parent", status: "failed" }), + expect.objectContaining({ relativePath: "parent/child", status: "failed" }), + ])); + await expect(lstat(join(external, "child"))).rejects.toMatchObject({ code: "ENOENT" }); + }); }); From 57a127913f796ebf00ea53d1bfe868e4769e26a9 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 03:54:39 +0700 Subject: [PATCH 08/26] fix: reject linked synchronization roots --- packages/cli/src/node-adapter.ts | 41 ++++++++++++++++---- packages/cli/test/cli.test.ts | 51 +++++++++++++++++++++++-- packages/cli/test/node-adapter.test.ts | 4 +- packages/cli/test/package-smoke.test.ts | 4 +- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts index 3a2beea..a9b3e0f 100644 --- a/packages/cli/src/node-adapter.ts +++ b/packages/cli/src/node-adapter.ts @@ -146,6 +146,8 @@ export interface ApplyOptions { export class NodeFileSystemAdapter { async validateRootPaths(sourcePath: string, targetPath: string): Promise<{ sourcePath: string; targetPath: string }> { + await this.assertNoLinkedAncestor(sourcePath); + await this.assertNoLinkedAncestor(targetPath); const [canonicalSource, canonicalTarget] = await Promise.all([ this.canonicalizeExistingPrefix(sourcePath), this.canonicalizeExistingPrefix(targetPath), @@ -161,6 +163,7 @@ export class NodeFileSystemAdapter { ): Promise { throwIfCancelled(signal); const absoluteRoot = resolve(rootPath); + await this.assertNoLinkedAncestor(absoluteRoot); const canonicalRoot = await this.canonicalizeExistingPrefix(absoluteRoot); let rootStat; try { @@ -223,6 +226,7 @@ export class NodeFileSystemAdapter { async ensureTarget(targetPath: string, dryRun = false): Promise<"created" | "already-exists" | "would-create"> { const absoluteTarget = resolve(targetPath); + await this.assertNoLinkedAncestor(absoluteTarget); const canonicalTarget = await this.canonicalizeExistingPrefix(absoluteTarget); try { const stat = await fs.lstat(canonicalTarget); @@ -239,6 +243,7 @@ export class NodeFileSystemAdapter { return "would-create"; } try { + await this.assertNoLinkedAncestor(absoluteTarget); await fs.mkdir(canonicalTarget, { recursive: true }); return "created"; } catch (error: unknown) { @@ -252,6 +257,8 @@ export class NodeFileSystemAdapter { options: ApplyOptions, ): Promise { throwIfCancelled(options.signal); + await this.assertNoLinkedAncestor(targetPath); + await this.assertNoLinkedAncestor(options.sourcePath); const canonicalTarget = await this.canonicalizeExistingPrefix(targetPath); const canonicalSource = await this.canonicalizeExistingPrefix(options.sourcePath); const [currentSource, currentTarget] = await Promise.all([ @@ -270,14 +277,7 @@ export class NodeFileSystemAdapter { continue; } try { - if ((await this.canonicalizeExistingPrefix(fullPath)) !== fullPath) { - result.push({ - relativePath, - status: "failed", - error: "A target path component traverses a symbolic link or junction.", - }); - continue; - } + await this.assertNoLinkedAncestor(fullPath); const existing = await fs.lstat(fullPath).catch((error: unknown) => (isMissing(error) ? undefined : Promise.reject(error))); if (existing?.isDirectory()) { result.push({ relativePath, status: "already-exists" }); @@ -325,6 +325,31 @@ export class NodeFileSystemAdapter { } } } + + private async assertNoLinkedAncestor(path: string): Promise { + let current = resolve(path); + while (true) { + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.INVALID_PATH, + message: "A synchronization root must not traverse a symbolic link or junction.", + details: { path: resolve(path), linkedAncestor: current }, + }); + } + } catch (error: unknown) { + if (!isMissing(error)) { + throw error; + } + } + const parent = dirname(current); + if (parent === current) { + return; + } + current = parent; + } + } } async function exists(path: string): Promise { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 51d324f..a331ea3 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,4 +1,4 @@ -import { lstat, mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,7 +10,7 @@ const directories: string[] = []; const cliPath = join(process.cwd(), "dist", "index.js"); async function tempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "rootline-command-test-")); + const directory = await realpath(await mkdtemp(join(tmpdir(), "rootline-command-test-"))); directories.push(directory); return directory; } @@ -173,7 +173,52 @@ describe("folder-sync command", () => { const result = run(source, target, "--auto", "--json"); expect(result.status).toBe(1); - expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "PATH_OVERLAP" } }); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); await expect(lstat(join(source, "nested"))).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("rejects a supplied target symlink before it can create directories outside target", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const external = join(workspace, "external"); + const targetAlias = join(workspace, "target-alias"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(external)]); + await symlink(external, targetAlias, "dir"); + + const result = run(source, targetAlias, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(external, "required"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a non-overlapping target ancestor link before mkdir", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const external = join(workspace, "external"); + const targetAlias = join(workspace, "target-alias"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(external)]); + await symlink(external, targetAlias, "dir"); + + const result = run(source, join(targetAlias, "nested"), "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(external, "nested"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects a supplied source symlink before scanning it", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const sourceAlias = join(workspace, "source-alias"); + const target = join(workspace, "target"); + await Promise.all([mkdir(join(source, "required"), { recursive: true }), mkdir(target)]); + await symlink(source, sourceAlias, "dir"); + + const result = run(sourceAlias, target, "--auto", "--json"); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: "INVALID_PATH" } }); + await expect(lstat(join(target, "required"))).rejects.toMatchObject({ code: "ENOENT" }); + }); }); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index 223ce07..7c6476f 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -1,4 +1,4 @@ -import { chmod, lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,7 +10,7 @@ import { NodeFileSystemAdapter, resolveConfig } from "../src/node-adapter.js"; const temporaryDirectories: string[] = []; async function tempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "rootline-cli-test-")); + const directory = await realpath(await mkdtemp(join(tmpdir(), "rootline-cli-test-"))); temporaryDirectories.push(directory); return directory; } diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts index a3e0a4b..8891ba8 100644 --- a/packages/cli/test/package-smoke.test.ts +++ b/packages/cli/test/package-smoke.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,7 +6,7 @@ import { join } from "node:path"; import { afterAll, describe, expect, it } from "vitest"; const workspace = join(process.cwd(), "../.."); -const scratch = await mkdtemp(join(tmpdir(), "rootline-package-smoke-")); +const scratch = await realpath(await mkdtemp(join(tmpdir(), "rootline-package-smoke-"))); function execute(command: string, arguments_: string[], cwd = workspace) { const result = spawnSync(command, arguments_, { From 99715fb2af400fa0b1d1f08c952bb2151fb93164 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 04:27:30 +0700 Subject: [PATCH 09/26] feat: implement Rootline desktop offline workflow --- .gitignore | 4 + apps/desktop/index.html | 13 + apps/desktop/package.json | 26 +- apps/desktop/src-tauri/Cargo.lock | 5250 +++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 31 + apps/desktop/src-tauri/build.rs | 3 + .../src-tauri/capabilities/default.json | 7 + apps/desktop/src-tauri/icons/128x128.png | Bin 0 -> 2255 bytes apps/desktop/src-tauri/icons/128x128@2x.png | Bin 0 -> 4435 bytes apps/desktop/src-tauri/icons/32x32.png | Bin 0 -> 719 bytes apps/desktop/src-tauri/icons/64x64.png | Bin 0 -> 1244 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 1854 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 2411 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 2640 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 4990 bytes .../src-tauri/icons/Square30x30Logo.png | Bin 0 -> 742 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 5417 bytes .../src-tauri/icons/Square44x44Logo.png | Bin 0 -> 991 bytes .../src-tauri/icons/Square71x71Logo.png | Bin 0 -> 1367 bytes .../src-tauri/icons/Square89x89Logo.png | Bin 0 -> 1620 bytes apps/desktop/src-tauri/icons/StoreLogo.png | Bin 0 -> 999 bytes apps/desktop/src-tauri/icons/icon.icns | Bin 0 -> 52908 bytes apps/desktop/src-tauri/icons/icon.ico | Bin 0 -> 9114 bytes apps/desktop/src-tauri/icons/icon.png | Bin 0 -> 8984 bytes .../desktop/src-tauri/icons/rootline-mark.svg | 10 + .../migrations/0001_offline_state.sql | 37 + apps/desktop/src-tauri/src/lib.rs | 901 +++ apps/desktop/src-tauri/src/main.rs | 3 + apps/desktop/src-tauri/tauri.conf.json | 33 + apps/desktop/src-tauri/tests/native.rs | 162 + apps/desktop/src/App.tsx | 344 ++ apps/desktop/src/components/DiffTree.tsx | 169 + apps/desktop/src/i18n.ts | 96 + apps/desktop/src/index.ts | 4 +- apps/desktop/src/main.tsx | 10 + apps/desktop/src/native.ts | 75 + apps/desktop/src/styles.css | 90 + apps/desktop/src/test/App.test.tsx | 132 + apps/desktop/src/test/setup.ts | 23 + apps/desktop/tsconfig.json | 7 +- apps/desktop/vite.config.ts | 8 + apps/desktop/vitest.config.ts | 10 + package.json | 2 +- pnpm-lock.yaml | 1106 +++- 44 files changed, 8548 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/index.html create mode 100644 apps/desktop/src-tauri/Cargo.lock create mode 100644 apps/desktop/src-tauri/Cargo.toml create mode 100644 apps/desktop/src-tauri/build.rs create mode 100644 apps/desktop/src-tauri/capabilities/default.json create mode 100644 apps/desktop/src-tauri/icons/128x128.png create mode 100644 apps/desktop/src-tauri/icons/128x128@2x.png create mode 100644 apps/desktop/src-tauri/icons/32x32.png create mode 100644 apps/desktop/src-tauri/icons/64x64.png create mode 100644 apps/desktop/src-tauri/icons/Square107x107Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square142x142Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square150x150Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square284x284Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square30x30Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square310x310Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square44x44Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square71x71Logo.png create mode 100644 apps/desktop/src-tauri/icons/Square89x89Logo.png create mode 100644 apps/desktop/src-tauri/icons/StoreLogo.png create mode 100644 apps/desktop/src-tauri/icons/icon.icns create mode 100644 apps/desktop/src-tauri/icons/icon.ico create mode 100644 apps/desktop/src-tauri/icons/icon.png create mode 100644 apps/desktop/src-tauri/icons/rootline-mark.svg create mode 100644 apps/desktop/src-tauri/migrations/0001_offline_state.sql create mode 100644 apps/desktop/src-tauri/src/lib.rs create mode 100644 apps/desktop/src-tauri/src/main.rs create mode 100644 apps/desktop/src-tauri/tauri.conf.json create mode 100644 apps/desktop/src-tauri/tests/native.rs create mode 100644 apps/desktop/src/App.tsx create mode 100644 apps/desktop/src/components/DiffTree.tsx create mode 100644 apps/desktop/src/i18n.ts create mode 100644 apps/desktop/src/main.tsx create mode 100644 apps/desktop/src/native.ts create mode 100644 apps/desktop/src/styles.css create mode 100644 apps/desktop/src/test/App.test.tsx create mode 100644 apps/desktop/src/test/setup.ts create mode 100644 apps/desktop/vite.config.ts create mode 100644 apps/desktop/vitest.config.ts diff --git a/.gitignore b/.gitignore index cf713be..be21b15 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,10 @@ temp/ # Build outputs dist/ build/ +apps/desktop/src-tauri/target/ +apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/icons/android/ +apps/desktop/src-tauri/icons/ios/ # Test directories (when added) test-source/ diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..03ee2bc --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,13 @@ + + + + + + + Rootline by baole.space + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 01f6213..68a0fb8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,7 +6,29 @@ "license": "ISC", "type": "module", "scripts": { - "build": "tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit" + "build": "vite build", + "dev": "vite --host 127.0.0.1", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.8.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.8.4", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.2", + "axe-core": "^4.10.3", + "jsdom": "^26.1.0", + "vite": "^7.1.2", + "vitest": "^3.2.4" } } diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..3a3932d --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5250 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rootline-desktop" +version = "2.0.0" +dependencies = [ + "rfd", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tempfile", + "thiserror 2.0.20", + "time", + "uuid", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..c6a9c37 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rootline-desktop" +version = "2.0.0" +description = "Rootline by baole.space desktop native boundary" +authors = ["baole.space"] +edition = "2021" +license = "ISC" + +[lib] +name = "rootline_desktop" +crate-type = ["lib", "cdylib", "staticlib"] + +[[bin]] +name = "rootline-desktop" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +rfd = "0.15" +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tauri = { version = "2", features = [] } +thiserror = "2" +time = { version = "0.3", features = ["formatting"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tempfile = "3" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..bce6655 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Rootline main window capability", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..df929e6d94218325a2e1c4936f36ceb4ac6b3f85 GIT binary patch literal 2255 zcmV;=2r&1FP)I>g#hk}D4cF=*shV_jT1sP63w--K) z$;JliU$E8}Lj`SxIhYDYhOWA**VfkffX2p$Xp@|sb2Ty1wz+AOcptuBNOPNmfT~nA5KB-U;}jrHDlt+y=PE&l#Te5>-)W-nG|nifxaefBrUmoxYFaiS(7UN27E2NM>83JQxK9d4q7eLY&+8wWsDRbMnl>p9~sFnaM}V4UsoD7078 zv~W=YfZ^=}0vo|p-{JdhCsi1Ka=jBoFFK-zfF z89*=g#Q8O(eD+TbOWf?;0L04Pdi0J%7)Z0VD;fD?^gC-7 z&~2Us;Q3A-$Vp1tTzP>+9lFGE(|~SsrZ4!!_&Icl^Q0)OR*A<5C0~ask)V2)+_H_X za4Z0-qVL-`ncUM<8oy!{@wv77?{$7JHCL6>`x87xJBR-M2z7Q|(vSQ4`t;)yC)%mE zw~y>o6QTgCtX8j?sr)OAU-HdXqLT3$ua~-Aw14)rqCzm3Bml>cw^Li&;S?ZNUK?N) zQ>Sw?oz#TyeuC1+yY5)G8Ej=r^$YfLhP{x zd>b2=(XnHH(2gD3sI06M0E}AW$7gL4i6>0gi4ueZ2Y#bjvuXf-v(l&6~IC3q(d{0kdWW z0AQ4oF@cdt->kI(0FVcFuxr;3S=%jM++fP}0sxQ)oj!fmP*5mSNdRC|?cAAt7BFKR z008nN83#ln!xwyNYH9$0os}&kFynHu0<%$GD~5#q004lpl!pO;O=V<_z-$1(1vp#) z002M$00ICI0Du4hROmW@*@{H~AkP5ERYAGxC!dr11Y`*~`5FMQxtg2TQY;o5;*J~& zT!7s*l<~8@2LNm)U!|W)Cq3T*6uB3naOggQKqts6j6Szv9(lYa8LvMS5d3@Ug8pnK znd$}+?pbrFWaCy#8T`q&n<(C`rAB@2(&4v#ojZEgRI>qA14kiwiCWV5%2-8={Otd) z*6c+e=xqUj4V387Qpb~LPBCOy=^A8P!~lcQ_)ev@0O-E{()as~Z~4xpt)l>79*?yE zxO*pKWE)j3Z5`zT61PK^0zd|>Nri725A1Sj>nH$tG-@pXs;9p+Y7UV-vH+XNC2nh% z)cw#s3Uy_#^WV7Gt+z!CFha#noy_pPW^e<^JC^)+=Ca5H#!&gR!S8i=xBCuVJAai> z2^@t61OUapMT}uMpAFDI?Lj@ws}zr9wEysaL?8Kgx^ELo!4A+(;R5?Ctw7uI~=4%wks##7d(c42jbd%ig)f6Tu z6q!K$YL|X^>Qa=J&G*t+W$0k=U`Ic-UwCX+0M4_@YV{!Jd<{8NG9CPG)myIxdJm(V zcD3E3KRch;_+QWVDvVK3%(#vP;9)dD8xGwO`Tp39Qn2*<@;y7tdvpCS`CZ9Ya+VDL1OH|tsc#nOanX6BaTx?st({5!7lp4SxVYGmH)sEIB)vT zs01=jd(bluv45JaQvi4r@uDCD-DCgJuiN@n05nYtV-|46=$E!0bTLShVYamaD8IG; z^wIws0Fn%`I@E%$BD=2qwWN>!7ui6jBK_e`Z<*gO4nIPd*{kj!x}oiPeO4Gk6;suq zSeKoLjSC1#;RDQld~j;sJa3eQC>_cLYga;Z~>-siCz~K54^O{l4KXVZE9b zRNR;etUB_g?W7HdZ)ZTNs`{S9OBwvpXHg~iy0=||U z7KM!KQGfWz#EJg%;!8djzxvSGCfnf2*cXGPdU*pNStlr;V z&=bbrB`SPPJ>A3m`%$}q!D54Gj9R#?9%3oE_=XpCeOcNy;_C|nK)R+>tBvB4jwW$f z4;Tt(bn&x8ZszFg(OUE4Itn1U(KL~udht-j7sv{f%85^h1#|Q1;z<*i$OPr?UGeD1 dmeP$x{{sRVBM_LK9vJ`t002ovPDHLkV1k8VDo_9b literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f43010c1f18fbd716d9c00376c1be93912e4a250 GIT binary patch literal 4435 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..bbbb3e257ac6166be919afea594d778ba0614041 GIT binary patch literal 719 zcmV;=0xmat{Q(N4^k935LRzTO zLqM>2@DDUa(~DFEvl_LfBBp8cv779UGpmV$nrz~3@?2(S^KRZ}-jCUNB>aY8Fz7E; zaGe6906hmsK~e-r$P#8%Rn69(Rwsv#1}7-!3@H3?69icTa&tSQKIBWW=A6<8Y%0Tj za37;Ry@B`pxonE?uon%Qc75l6iG(7OkSP27QMSB*_Cq#IqDidaE}=uQStgJl3z(Cl3 zDcFgCFScxrgY)<5+KrE2!SHazionRoMO%@On#matbEDtq=4LDs(daEqP2F)O1KLGW;(zX0vxNW&p-%qo`4u0Y<#>Gb68l8vHqe^yb6l> zqH_e4+YPEZoreGUWAvXmU@Z%|0=C|Mf@#t=rNHNfrWv(malhnj3ihv#HMG&XJV?G% z**R}Q&+Bz}?@AGtbqz+PY|Ty6>`WKfaqKFAj-La~;Qzk`0)Z$9oryqSkAySFWORE7 zUZzXf`c`(AK=oc=T_kq4aq8(TrXxR`-hEsd%{^F9Co002ovPDHLkV1n$r BQds~1 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..a4f2e39487af2d128ff812833844ffbff23ddab6 GIT binary patch literal 1244 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d37389f1fb8e687c482afc68112734f64f4610b5 GIT binary patch literal 2411 zcmV-x36%DUP){x4>D1OV$n#O55hyrODbkt5uvLzpC1p*ywLY&YKZ3AoirTey+ zjY8LbSO;rpp~ViRltM$@4Mr)ncDCe0Nue@hsSRzpabBi0AyzDB^&ClO=ebQ}%a*Kr zRZG5C_x}SSDSELu_s8=*=Q-!z=u|-r!)TsP(>C?6n*ug$Sn0xmU@X~6o+*gg(5L5H5wEeeV9-dxl)`+hjEBy zktDlu$lNIrX*7@+H&41k97`BUeMmnZN^64peyBiYixX)xIIeY+p3l$Z|Dty!Q0gB$5vnMWe+UKNzwk=MpVMNmzIi%{DW(;&q z=B}h35IgOrF3nb?X*YV)Y54`DsbSQy<-pL=Zp4-(;;=Mo#?+6I^$!6d_FcJX4wfwr z&zw{dJcOY{+OjPUY8uER0%Ciyf>|rJ#*|J|Kcw+@&GY>Xx=1=J^p6dp=9kqcV;DW( z6A3ki!?z}rAC*j1sB6G{bX{zpU+-uIavNq)AdgI%{ zL&q>Az?L{!ip@<`T^JHzD_h+WM<7iBr>xLLSkGqFIZpsMU?xM|x|D=%puiDX<&18f zUFi`5om;7+qn#S{05x3ycG-VVDwU$q(J?=6xiECqPIi4^T=%ms3jRvSzK1x# zar8NL>OCukc^Msta(Q^^rQhX>XG9#}SRSvYt(u594?bA^Rg;Gw-eKLhw6xIUkME?% z9@|;g_i(|yfB%!7o36XMkHgdP6)*m_Ex$&qN)FQw4dPq%E&R9f(tmqkQa5%o%@{ZGz1|IC@bnf9I#-9We2zSb0CgO zal1I!$W>)((_G6?|-P1+O~EqcwaEIf;Oqw*0iss ze+|VcJ(GqweidiFG#jJ$6_jQ!&5sx{-o7Wfhh+Adc2y*pQ-Ng zW{MQ(({MT)RdRh-LN18oLs%Y;Jm$zZ)>;+cI0|twoVt<1A}3fJjw*b+HvTPPbsP{j zs{;UW0K@?h2S6MEaR7ig0OA0M10W87H~>H#0C51s0T2g38~`8=fH(l+0Eh!14ge4b zKpX&Z0K@?R;@D-I(Lm7+4YVmzPl;rjzPfsw&W@^cy{EGf2P`DgtkZ9L!W32F%xCK7 z&GhnzH!OktNKO1FAWOo3Z_%w6e@#-|GoheH?>@g?-HSy(HZVL%Cr?ij{=X`plK$fJ zoHWeupS-7}G)!-I6Wym$7{no5$l9uD3Z$o^@^m$!#_{WQj^|#;nhRkkY>L!D9KT*! zJ?}!+LCs+i9YCt`D|y!=b~w$RnwAj9P0W1!cXgF)S@r7?bDzcuUnOSzCypR-!PT>! zGs9KC9`T6zKh@~V*#7LP&!+s=#Tns3st$3nehy2+ zg86OfK?reDC+Y5pv!LdwA|J(XQCy`htI4;n&YE^}3j12sK>XSnpDs7BGoq#4h-IYw z0P`wfdj=}RKCM1L!x#$SsAN#iXr^f74!?>W9~nsHSG^#(9ys7%DYO4M`2D~=?uffz-VbhWFF_P+^u_oSZ<0VTdfnB;O#>vdhAI zgn7H?V$D2Ph%*=EQ1eWNy0cQv#snEEA{pvL-e#Vg1qK85A` dGgvCa{{iYDOb8Vio<0Bo002ovPDHLkV1klMhOz(v literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..eb40a2519cb600cc9c8d62a133ba7406a0dbd9b7 GIT binary patch literal 2640 zcmV-W3a|BvP)tFR@F2mA#IdS>J-v-oz}I*krpX!@_}et2UE6OAW&y(sd*~~Y@eNT3C0G$ z+-t7E*Z2REoNxhyef;Bjp7We@eciMH)T*jVQEC-8RUEd-&vC|m#K}t_cQ6Vv&IYa@ z`N>6^xSb7XnwI-tog?ZR)Q%`oCnw$sf%42Z#-vz&7g68%sCFjr#nK~FgbqR`n?*HZ zeWcKHRz@;Q#H!t{%Je3MYQrmK>qvH71ccteRn5PtV#Oj9=UsSN3QI7LLO&G&EST-j zP_n&aO{4k*Mfsb;@UP>gC^&p!PLG#)t3tDr!H+g1Vu>K-or};h;-ZcMP6%IpB?|q( zZ1gfER=ukFA`v<*QlE+uch)RYQRsVPnl@m$SfYK<86kcQvpwg|9^DFSH!7E*8mqQJ z?TT`KN_^2!R$$$3xmnZlX2Dp*;*7d6G+CMnE{{kZe);mMI zHac7ti&*o~#QX?AwlhSzwj2|SM+uwufF;N?vvjd!8Wcr)D~1GEgzl1iA+mG=DF9Zj zAsZ9<$Q2i%Q$RNj10bqVn=`+AjV)TG=Aq zB%Vz^FgQ3w)6>)X?ZSnN`my7@V&r2r+jnrgJmBJo_fyfgzO&}@<%)~Ccht~#x8~p9 z$k^C8eeglQF4>14_B&WYi<_Bl*VP1EbIpQJb5 zIH`YJ(5H))Rd{>s@L)YZ`U$bh)EinCIM<;J!_n~u6YH$yByIoY0r8Vi_We0-d+=N(WkdRnNl z0DxEkVgZN+0K@_i3qULY2z@Nd|Gn^(WamRXXU@Ehhy~1xRb5?06BA$Pi_*&r({iu7 zJn^-usfpfs=T8s|nCIxxUP{)HU+T*Dqs9UNV&xg0vJxt*E~oP9rJW3+nK_!Cn5LPD zX@~`~qhVJgsdwB$?i<#=ELT4np(~gEP2q*GEuM>Q-j2KM=$o0p>C0J)j11@hlbH|E z{PEvXbaLgw@3*)6XiMYPw0D!y>%t$;+d{6widdyL>Q}DFcNYKpZh}-YQe~2AIZfWW z_bc@G{sBTC3#=g;7geP!GW{7v3b>{5W{3rnh&8$*mMqkh)l}(NF6@yCK?0ExZC#Rh zYpNg?SUbTWtQCS>OfXzqwkFK<%wmV07R1_&L2uZxE=fYOa}W!pzu=VYs>|l5nP@d? z;`Bhmc4EoGxr zMk*=Qv`y9wF)Pd?xnxIut7v}8-j=tvDkfBDCM43|FkulIWX=CZ{;S^z+hzcOSO8)H zhy?(|0uT#8EC3)DfLH)x0RXW8!~zft0Eh)37Jyg)Kr8^U0K@_SVgZN+AQpgF06;7N zu>iyZ0Ac}%1t1nM&bNIx($1P9+F4tqA72hE(&cN5^p}BI3=YIHPp#KY2Rf?ArxquC ze);&`J4)#nzyF+OLQ%($RbgH7#6lv2;!WIsx$-7%WgZuudSaXSpQXLuOo-FNzxmYh za^bBaQxHCPHsSdBhi=L!)slcrM73(drF#Kox_ZRkmXbB;FCBNac<7ee8z2@)fAt7i zsGH@kW)F&Yu(rO6XJ;1CM;7`J3nb2H;@&EIsUSCBh@*J73kwUI@&^BsK+yb!Cj-HV z!^L83(hd)F@%=w<={^y3~94i$gHtb@Y2JG-)=ifRdE+DCGoLG%3 z*SonKSfIDg<Y%asO0SQLcc{l(GtMm-s-nMbz@YPc-)>xo)l@RwN2)Maf+#-&ViW z%UWF2kN-KgKznw2sJblu@080s&;Mb<;YBLD#A>&zJ)HPKl~<^tC!LbRbQ%IwSg4ou;-XmnhUF*L^U0Ak~_nbj0+Y zcQJDeDsJjTy#P#fd|@lr`AR?h-67VoQ>4h93|mEM5(f$ehA zh>>-+RN@?o&QC|Q^9Gi8j8U(uzHmfriNHcNB9)cr6)vYm6!gw4)Xhj``h->agFSBY z$!R8r+c_--`I?)N%2X;`&9+9hJIcjsHd153ral*=9#Kyn$#M%#_K5Q^yrBpk;haB) zSE8`i{^T-Ex^_ZN#ac$3j3HVQ)CIxpFO=M+U^eaLOstq-B7{+d#0jvXv5FLFpB94L z`6)K(#qX!eE3x9KYfw9*x?t3XQ_J#imM71qktaTkk7|86+NPX)u1B&qA(<);+vMjs y6SGT1wT)Tmuc&w5wLZ)Idk#s2|hJ9ihJzrFYX0000k+QUwuW=$%LvAt=2ERC*JTCPEOU z3!(QCTIir5NQaa6^URs|%$y(R$DX<8UNdX0z4yA-JoCJU>pWm$;9vj%fJsALO%DJl z!2djSG-QX3bRG`?toJn3lnuNmHm8G}r*;F{fA1}Cy~4eI(Tit>yv}qDuCah7y}6vi zce9n^b+(aHbit3j!XM7@3}MQB23DmsE)R0=pHLZ<8uDqs(T_6XVfH*5i+U3Ifmi?G z)YIh4;QQ^e;dlULy20b=?=>5sIQye4b1HMYeebA|dv;kBnK}Q1j~iq86}F9wbyZu_ zI`Vz)r>-Gl^Uy5evGsbb&*9l*%?o11KGJjP9Lw*wghUp$d0B3G?ADep z1s!-KvFX~`Hk{IUtYg(P?kJnzkJFluEEKA~2o+GmxI-{xy#?`ZB<9xTNk2IlDm zSRnA2O|H8i)4RO(`wff&Nlxzi`}fjXl!hUR+rPfl zF03XvX-DiP%y50xJ(KA2x>Vpqa2V36_|YwxM0B>)ev&mH3~%)3Mx~i{uEnl}S6hBCn~I4?VlUt%^inUnDv{J0q+6@faO5V#8J z=5m?-_JNKQ=q|Oo*Wwhxne$<4+{Om0ItJ{TudduDCL#TxH;g%ckaLXwOnm_Hh2Ajw z0PGK|i^?K0J}`tbG1v8p`)&NdWwssGsbEf9_9XU|uD9-@ZF9YNF1#Mqwpn&gSYZ&=Ij zD)>4^H!%gbmRKLkMipKPuG}7=FTNdSX3SwNbS`_UFwq>ZcTkBMoO!y7oRbZ!Nuzv`P8G5U`9@ntv zl{ClFtMUsNBGzb>WDR>eU;iF?B@8a&1eOeB?L8WP2`0u(M{@*A$UlPPOEy3G{@k>P zz7mXdp_4MHbjI5{`$W!B!plAf#yQO7Rk+y;<)*g+_*ZR_PUc_Lnm)Ev?1(r}C{>U= zJ}SuO8)$ZlhSLStjdYt#ynm#X4|r?b8)`c!7QQS5PQ2KwMS*}JF57jh;K!&zz=f5> z?JZuE4O(JFe{PbU1K2tWsF6XrDV`irQv$WI(2}Z>eHHvlTm=6!%3m%|66@hZKlcy? z2*7)${Uv3)(I5M8>R@kAn=l!6R*56mx+uzEZ~v4*`V4s5DHE`r+V(Ouyy8gZMKN!s zU2!P%w#jh6U}mTwty z!1~@*8n2MNIkD|{#AsevkaY1ZpIB34$!wbH4Il6C90x6H!c=lPkm=8Nm)k7@JRY~T zp=EQVcwR49B%8fNqtfF>{*;(JlPwVrN2}PjyM=ecSu5XF;ypjN!Lc3(UxLjV#0Q^CVi>f^7On( ztxM_PUTT_km!T?3@|ufNJ(64X(U)-8?CqR&MROwNtcMgmh~$#YbJI@`HuZ&e)<2giK zau}k|vY_2oNvk8KUL89)7{BGoS|j8A;mQSS9a*1%T_vCRqG(qQ>%7jrWXrx|5s3_Bu-v9Bn0-dEcH&JuBvLtzDG|hiIfnH zZt6X6x-xg&YPfF?i`8{$*xb*fy*N4b;b zw!e1!geQ9YSGP`kxs{$nQOy-HwP*B+Z#TNn2J9ho$&OiyeQMutWRzV?+f*(UB`qRF zaAc-&c6VcEdt$dWEI{=-0yvnvfJt=qUwDaA#MX1OQ-eM=gPTZ2136tc5WyvhXk!Jg zA}}TGTW19>F{5&=#P3mTC@S?Ds!tXhgY`>ET#TY9st&t{1PiHmK27A1t)0$$EU`=Z zrQe=-j}0YlM^^@xO_;s2cgqogG#AP0ee z3p((BJAVbU3_u5N7AtRpnpG!Po;QxBY-wzpKRM|j=agsG_4n;1zb+k)9S6yr|Du-@ zL8VR+0A{^P5Eml|f(WBKhPYgXYi=PPQ78q3ud$Zn2#>xxS1Y*RTk=hsrrjoLB*z)n=Nv-URQdmec^sMTvgI&6f zNp9XQtjC(qU8|KmcNBN?sU8qX&Ql;dR;yN>ZY6v+$QI3yG=kW7PD(|#h6MJt^^A(| zQ-fadc`U-W*78DWUgnIruDMe(QG+OX0{qR0rJ_ECi54K#0{<|x9kY-^Zd%7RnLLdR zk8=YfFPUb6q}Bk&j68dqoEGA)>+#qm6=geY?v4#SH?5p_3ZoH;WtCCXE#$Zw%1{nh zUYVoL&wzDbHz2l7IxZbQUOUcMwwwk3A|J^t@gE6SLxt`xeOG=6a$}L1-($aKuzzl< z2D%QzWMK&JRhZQ?u^5es65!5#c4SX)7A^ zITjt%3<+*leydh9m9k?);Gkz^v`~*e6${bDUw@XcH5clpRUBO#+r(=@a;amDXjWD# zt=rs@h5gCN`cr2TlM=OBogCPjUG-bn=~U}&Pmyt_q0UO0XQ%(2)ZK;c=wqGt#qS3! z2y8j_vpudL^kHRtL^CSi{%kY&WBZKNb$KG;-K?fwxloTK>ys|2E(7UMCKT1BA0+di ztXHGz`GyScx~}s}_f?T;f1l~*d*^%aTyIV9^Ge0^E|Oy$e>^lpaYP~eLZK6R%XTRq zTFqz$(f(^5f&WnQ4>x}WndW~BB|Pxg`745(DgG_U4E}ZgivI=U|KRz5QThL=mreNG zezTJAa#G$i^)kyUmHTZDnL(!wowBJ}op+STtK=H1s2rp`ZgN3*>F^JmY|yN_wNW6{ z*T{lH_Ivh48KvKzZN{BcayC%+p-L>o<*tltrTJN-#ddt+hMe%1P=2_I@3T2~J? zxQ%06O^=oJsz|dUHi@jCPGO&KE|CDN{xnMt4CkTGNNnnxK7O6t#Uz;f zaufrrMXch{#bNF1#QRJ|)m%op6E&8$GG-;rii5nuo`K3L>(I++*q8$@Nmfw$`N)a+uX-aTvsRMew=6hC1%#g#jL+Wgn_+ytflBxd$h$R$y1g3-59c+ErB8iyq9iZrA>m3LLtPOhsd(Vh zf-KAblo%a(n;P^nBeMf{X5HmOCAwrAZ8jD0p_cQ4I1lhu0waEfl3xuE{JMh>wg1_o z=BFKzIK(+xX8R1N(=0rblYJLy?)w=fL>qi1TvYDiJ#O0Ih+s zEEE4)?NxUj5ZRsVV&0J?GOE9#gM?90H#Y`W$ltEt`tC?x3v~h<786A#EHYn+8f)qt zwkbdze1;L48n@jBJ9!2v0t0s4NxUZ8Z1_9;00i`0Q`w6{c-5u@NO&g`Ae%LXknOIt zULH6svq3`vfdv6oWj3@UNx*Lvc^SBr0P(53wQOVk{^Ci5yPmi|5LM`su^}LhC_n-U zdedfKE;Tt{c<{J_5x6Is$y>iJSHB^!#MrXk&Ev(oCPQoq(_R z`MER|8s{FqIdkJ3z*TVcMaX%Hla%`G_12mf)!x@8z&M3=-&mwHyj+Dce}35i;hZR{ zfkVcAYviiC9j0R;bkr>{kmqv5;EH* z8TZe|dv6M@*H>kB8YmkbuX_Mee0wp1LYx+e!30E~cG8H$f?J z*6{=GJ{$72k)d`WPj?K;*SIn_eusJ1FdJH)atHRoA|ghNt(SLL2UF(x2fhgmKN=1t zu}aLsvb-m-g6X)4-HH0lkyy+eo*&*{)Mj=bS}6DZ^iQPF50WRTA@CacPNU(SE_Qv_ z=Fq?p^JNxEhe};!_@gjxZ&=QM^+}Og) zaKexVBy{(l$;t5Uwqfpw=VJxO9UfR(x6-2?tH$)&vma{K+0c+rU9y9vpGjji^>)#! zIZ8Edx|*ww`j2Ufo=zAHqTE*FzI3VUwt1o-x({wE!;d5mgCa=R(r(D{Pz8#;pyfk| zR`*uUr$)P{0|PM?G6CE}&tdn)3Zhej>BDS0N*f&Mz{x7=t|Fh^=~{PZdk2`FJwLUn zQIB?HFLHr?#L0;rz8)Ig)l`Y-j+{wJE?7cz63!@|U&wHk*T#;L@6`Z}dpc@mDps%l E3%b2u1ONa4 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a5798e7a2c83d822540f798599b1036744bae630 GIT binary patch literal 742 zcmVn)M)hz7TXwVS7~F^#5(BaVwxDI+UVfcb~H^Kw9eJVI%t5&bf}?B z#zh@85il_hiUUdmy%qx%j`G(Y=X*z?8s-0SewW|l@#B4XzxVsS`<)iQ>9E;4E-{z} zq;W{-ff8bnV8FP^dajK~=+*A@j`}Rt1xV5iIw^*^9}S0Q>5!FoYOy*9^d>s5QwF2S zlok@t89BU=UP48Y(4S`w#vTV5TU7;9^_d5xnojzC7`bo}dOlp$hv#{$t?^AIoVK2i z_1bl;+E3%*iK94a)K~Qj1ioNy?zU{}id0Npa4Z&=?aGP=?^21sz8@a8f#W#YE-x=( zYU&2OUVmM7U2s01hmKgc+XFH1sjA=Dm>mwsW6aOr!NkNB2tumnx*DYthVGI^rN5uU z=%~G}zedSyEhiWad)uLIMn0dvVPCDn^$EZKJ#vMDa$wdnK+B+}KJyXWTU@|qBuf4S zahKWvvqQ&atGhZ`(@*ri#bEFaOk6Jxm`%vM$RH!6TL(*-(vOSb)ynvve(@NDR1Upl ztC=5Zh=uL;LS0-hWorJZt7aWjtsC|t{*5P45&2wRz2wSnWsytg$k|HjX0;sh2A~}Q z|Cc&e1xzoCgWX*uv_-@OvEdLm4Ayj)n$4~==fK)VDjlNvs}Hx%79fhi-Dhz`Vg+Tv z%n`2SNWi-f>F*fKe#ForQT}M-bl~ILD3aYb4rZ7niBosg=kb$8=q_DGaWnqMd@!Yh z=R*?WPZ7b5LQ_!6WJqi4B&E|(oLoR-2%QY)XO+$BjE+@MsBto>w zrp>lW&FzC|B=ksrlc1fwI&c{}Q+b6c!}l9auW7GM4&fM?+M`f+kR(ekkws_ePRsMZ Y0FYD(+uCFIxc~qF07*qoM6N<$g8M9G5dZ)H literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3f550691cccd4e92a7fd9c5f810316af3a181b0a GIT binary patch literal 5417 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d9ce9517a93289d5372aaf3101029a597f20919c GIT binary patch literal 991 zcmV<510ei~P)q1;scm~8buSL z#+?fl5_JKrG?1Do4k)Dtjg?kAl<7R*8*FOX4lgCN85HHng9srC`&$YX@JJGHQ&E)QihGv-qPkYT%plL@3-a&u2og$q1D}*D zs{$KY)$kl(8MTlI$WVz5N8;HQ;uow4%SA!kRSgg0=OX`6RV(+hOI}!4vdg`Rgy(}p zq?&nwYV-rFF6L=TB-=%&tDI#Q&9J^obdb+tX<1nxKf!tsC6di-`4a17G@ZYQMyCUZ zNc6w=`~80Sd~c1{=;+v5ZQ-$*joH$57L(3$OtRyXO$}%$P5;9g85u)Q&n3*v_%ll| zrLtf)D6Hv;<4{aD72DRc1YUAP2s=dPt%^Y}g-{*V8SX>D!6 z>C>m+a=DE2`S}n!I*wec*5iQ-Rr~Zrmwh#y1Tj2%x#NM9N3ZVAJg-{?yc)4-gorwy3R-I;^v{ zR5#h0ZY&%dz#G5?QcfFq$u3wc!{Rm%->q9#vvd^s73{AU(NbTGu}Kv};}OJkS{Ptq zbF!DPpX^+5U3)o<-LLMV=jhTm$&!Qy= z`Odaf%F+#p4%}jyJ;Qp%MWe4+*+VYk>01q5?G(-ug0M>Q!WTi7Hn*n;8xah#P2&UE zd@w|T7@plxZO6Mn6j|Jj9~KB3fMokgjz3`8gf)N}D=}spF(_tiQ4lffN+M)|4?74& zuhka}&SlAsr`Vgqt3k1V{?$gezX0Z^XSLyNmVW>M N002ovPDHLkV1oGI(@_8b literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6f1ef2a25fe8fd8497c91a26b8176f9e8002fc54 GIT binary patch literal 1367 zcmV-d1*rOoP)_rfYr3Iqr8&u(!6aS0d+FPiXYKI};_Gx5P>f-%PMu*3)5!;%e6d?3a* zMITU-B}PpogBKH*OdxZMvc@sgjV5Mx=tyyYD6|erZ#kZG`)Bu#mSMf6y}jp~+}y5( zvz*Uuuvy|Tn#$m&>&+GN>>{lNK zrOwsKZl@?{C-OuDWQyJ1_5J0YN(+t}DGEmO2})8(GWt_RQc^fI^{(Cs=%^{u#+bBw zF+UoaYJ z$nm5Gmktcq&#U(g8~@OuE}T94Esh_5dsk{6L*byPXjel1=+Rd&Hug12OC1^gdu%YN znwlChIy#b3K!$pb)YObe-Z`mzZ)4&c^-4$W?QN*3sZsAeJujs6p<~D1$_N~(q_k|% zurREA6a~+A=*tDqRrN|o+<5s)#R2fuibHZZ^pUcE|2`}(Mt{z-X*DzB#*rg0Rq$N#HOr>7F5Pr)A$=od0qX<<%7|ioyuo4Hyb0FDhgXDd9(yiXu?#tppqa zN5Bzq1RNm&N5Bzq1RMcJz)3E?qpm2i;q`7Os>>wxy;*MvUtJ6!6sGwJr?BTG_1aFM z&ptu;!Zbd5ZEpe$SF39eO6)d#{GC55a11>OBz8N}Jr{0aAhclOI^0^EwP-dLPGNd* zA`9M>pOx527U2-GV$@*anTzb}qZ>%)TsRO){+Z7oL6%H#MRJsu9cC|GMvbZb_=7rG zB1za>@?q1r2zuw%_E#`~J98eERx+NbF3^oPG_j0@+e$W`_xy=e)w{;o`C?D5tXkZJ z`4wEJIDc&oODjsYB4>i++7rQSMF*uhQCjrdAYm$(3a?xM6E#9i+4y6^EWf5jJ*@K^ zwHZi!DIXL_bQ(<5Is7Sn5j~oyiFGBgX*LplM%Fy$B*W$}-+v%D0Y~*7);P%0Y}ov@ z?P7GBnc%A5>u{FeU@v>E7c``wrPKIB`{(d>1(dgNqSR@HR8k{mC;f901}&jA-%Xm& zkIyqzd#auG>>6YZFgo%H3ZGNjJ-d?j@M0|t_>=n4Zf|HcMcPHvCfe-{%qm#ryVGuO Z{{kK?_XG=aXCeRq002ovPDHLkV1n0Rl%W6s literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1f57cff570304553405a2588d7ce6e0013931da8 GIT binary patch literal 1620 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..601f5444edefacbb1b1443e3aaac014ff7a4b72c GIT binary patch literal 9114 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

TfeDXN32{ULdLYOPn|%7cL{)jUpS2#D<1{&0HQu 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 Z8>07tGKciOf@erA9|36l-~S!(zW~lh4SoOs literal 0 HcmV?d00001 diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..79db512274b52c4c8d4367f975171cc94851a46a GIT binary patch literal 8984 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 + Rootline + A cyan root line branches into a quiet folder tree on graphite. + + + + + + + diff --git a/apps/desktop/src-tauri/migrations/0001_offline_state.sql b/apps/desktop/src-tauri/migrations/0001_offline_state.sql new file mode 100644 index 0000000..31b4848 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0001_offline_state.sql @@ -0,0 +1,37 @@ +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_path TEXT NOT NULL, + target_path TEXT NOT NULL, + exclusions_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE mutation_outbox ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + mutation_id TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + occurred_at TEXT NOT NULL +); + +CREATE TABLE sync_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + epoch TEXT NOT NULL, + cursor TEXT NOT NULL +); + +CREATE TABLE run_history ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + profile_id TEXT NOT NULL, + status TEXT NOT NULL, + created_count INTEGER NOT NULL, + result_json TEXT NOT NULL +); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..5b8b772 --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,901 @@ +use std::{ + collections::{HashMap, HashSet}, + fs, + io::Write, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{Manager, State}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum NativeErrorCode { + Cancelled, + InvalidPath, + PathOverlap, + SourceNotFound, + TargetNotFound, + UnreadablePath, + StalePlan, + Internal, +} + +#[derive(Debug, Serialize, thiserror::Error)] +#[error("{message}")] +pub struct NativeError { + pub code: NativeErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl NativeError { + fn new(code: NativeErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + fn at(code: NativeErrorCode, message: impl Into, path: &Path) -> Self { + Self { + code, + message: message.into(), + details: Some(json!({ "path": path })), + } + } +} + +impl From for NativeError { + fn from(error: rusqlite::Error) -> Self { + Self::new( + NativeErrorCode::Internal, + format!("Local database error: {error}"), + ) + } +} + +#[derive(Clone, Default)] +pub struct CancellationToken(Arc); + +impl CancellationToken { + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + fn check(&self) -> Result<(), NativeError> { + if self.0.load(Ordering::Acquire) { + Err(NativeError::new( + NativeErrorCode::Cancelled, + "The operation was cancelled.", + )) + } else { + Ok(()) + } + } +} + +#[derive(Default)] +struct OperationRegistry(Mutex>); + +impl OperationRegistry { + fn begin(&self, id: &str) -> CancellationToken { + let token = CancellationToken::default(); + self.0 + .lock() + .expect("operation registry poisoned") + .insert(id.into(), token.clone()); + token + } + + fn finish(&self, id: &str) { + self.0 + .lock() + .expect("operation registry poisoned") + .remove(id); + } + + fn cancel(&self, id: &str) { + if let Some(token) = self.0.lock().expect("operation registry poisoned").get(id) { + token.cancel(); + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanRequest { + pub operation_id: String, + pub source_path: PathBuf, + pub target_path: PathBuf, + #[serde(default)] + pub exclusions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DirectoryStatus { + Created, + AlreadyExists, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryResult { + pub relative_path: String, + pub status: DirectoryStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanPlan { + pub operation_id: String, + pub source_fingerprint: String, + pub target_fingerprint: String, + pub target_case_sensitive: bool, + pub plan_fingerprint: String, + pub missing: Vec, + pub skipped_links: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyResult { + pub run_id: String, + pub started_at: String, + pub finished_at: String, + pub directories: Vec, +} + +#[derive(Debug)] +struct Snapshot { + entries: Vec, + fingerprint: String, + skipped_links: Vec, +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|error| NativeError::new(NativeErrorCode::InvalidPath, error.to_string())) + } +} + +fn canonical_directory(path: &Path, role: &str) -> Result { + assert_not_link(path)?; + let canonical = fs::canonicalize(path).map_err(|error| { + let code = if error.kind() == std::io::ErrorKind::NotFound { + if role == "source" { + NativeErrorCode::SourceNotFound + } else { + NativeErrorCode::TargetNotFound + } + } else { + NativeErrorCode::UnreadablePath + }; + NativeError::at(code, format!("The {role} folder cannot be read."), path) + })?; + if !canonical.is_dir() { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + format!("The {role} path is not a folder."), + path, + )); + } + Ok(canonical) +} + +fn assert_not_link(path: &Path) -> Result<(), NativeError> { + let absolute = absolute(path)?; + match fs::symlink_metadata(&absolute) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(NativeError::at( + NativeErrorCode::InvalidPath, + "A synchronization root must not be a symbolic link or junction.", + &absolute, + )), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + &absolute, + )), + } +} + +fn assert_no_link_below(root: &Path, destination: &Path) -> Result<(), NativeError> { + let relative = destination.strip_prefix(root).map_err(|_| { + NativeError::at( + NativeErrorCode::InvalidPath, + "A destination escaped its synchronization root.", + destination, + ) + })?; + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + assert_not_link(¤t)?; + } + Ok(()) +} + +fn path_key(path: &Path, case_sensitive: bool) -> String { + let value = path.to_string_lossy().replace('\\', "/"); + if case_sensitive { + value + } else { + value.to_lowercase() + } +} + +fn validate_relationship( + source: &Path, + target: &Path, + case_sensitive: bool, +) -> Result<(), NativeError> { + let source = path_key(source, case_sensitive); + let target = path_key(target, case_sensitive); + if source == target + || source.starts_with(&format!("{target}/")) + || target.starts_with(&format!("{source}/")) + { + return Err(NativeError::new( + NativeErrorCode::PathOverlap, + "Source and target roots must not overlap.", + )); + } + Ok(()) +} + +pub fn detect_case_sensitive(directory: &Path) -> Result { + let probe_name = format!(".rootline-case-probe-{}", Uuid::new_v4().simple()); + let probe = directory.join(&probe_name); + let alternate = directory.join(probe_name.to_uppercase()); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + .map_err(|error| { + NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + directory, + ) + })?; + let outcome = (|| { + file.write_all(b"rootline").map_err(|error| { + NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), &probe) + })?; + Ok(!alternate.exists()) + })(); + drop(file); + let _ = fs::remove_file(probe); + outcome +} + +fn normalize_relative(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn glob_segment_matches(value: &str, pattern: &str) -> bool { + let value: Vec<_> = value.chars().collect(); + let pattern: Vec<_> = pattern.chars().collect(); + let mut matches = vec![false; value.len() + 1]; + matches[0] = true; + for token in pattern { + if token == '*' { + for index in 1..=value.len() { + matches[index] = matches[index] || matches[index - 1]; + } + } else { + for index in (1..=value.len()).rev() { + matches[index] = matches[index - 1] && value[index - 1] == token; + } + matches[0] = false; + } + } + matches[value.len()] +} + +fn excluded(relative: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + let normalized = pattern.replace('\\', "/"); + if normalized.contains('/') { + glob_segment_matches(relative, &normalized) + } else { + relative + .split('/') + .any(|segment| glob_segment_matches(segment, &normalized)) + } + }) +} + +fn fingerprint(values: &[String]) -> String { + let mut hash = 0x811c9dc5_u32; + for value in values { + for byte in value.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(0x01000193); + } + hash ^= 10; + hash = hash.wrapping_mul(0x01000193); + } + format!("fnv1a-{hash:08x}") +} + +fn scan_root( + root: &Path, + exclusions: &[String], + token: &CancellationToken, +) -> Result { + let mut entries = Vec::new(); + let mut skipped_links = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(current) = pending.pop() { + token.check()?; + if current.join(".ignore").exists() { + continue; + } + let mut children = fs::read_dir(¤t) + .map_err(|error| { + NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), ¤t) + })? + .collect::, _>>() + .map_err(|error| { + NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), ¤t) + })?; + children.sort_by_key(|entry| entry.file_name()); + for child in children.into_iter().rev() { + token.check()?; + let path = child.path(); + let relative = + normalize_relative(path.strip_prefix(root).expect("entry is below root")); + if excluded(&relative, exclusions) { + continue; + } + let metadata = fs::symlink_metadata(&path).map_err(|error| { + NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), &path) + })?; + if metadata.file_type().is_symlink() { + skipped_links.push(relative); + } else if metadata.is_dir() { + entries.push(relative); + pending.push(path); + } + } + } + entries.sort(); + skipped_links.sort(); + let fingerprint = fingerprint(&entries); + Ok(Snapshot { + entries, + fingerprint, + skipped_links, + }) +} + +pub fn scan_plan( + request: &ScanRequest, + token: &CancellationToken, +) -> Result { + token.check()?; + let source = canonical_directory(&request.source_path, "source")?; + let target = canonical_directory(&request.target_path, "target")?; + let target_case_sensitive = detect_case_sensitive(&target)?; + validate_relationship(&source, &target, target_case_sensitive)?; + let source_snapshot = scan_root(&source, &request.exclusions, token)?; + let target_snapshot = scan_root(&target, &request.exclusions, token)?; + let comparable = |value: &str| { + if target_case_sensitive { + value.to_owned() + } else { + value.to_lowercase() + } + }; + let target_entries: HashSet<_> = target_snapshot + .entries + .iter() + .map(|entry| comparable(entry)) + .collect(); + let missing: Vec<_> = source_snapshot + .entries + .iter() + .filter(|entry| !target_entries.contains(&comparable(entry))) + .cloned() + .collect(); + let mut plan_values = vec![ + source_snapshot.fingerprint.clone(), + target_snapshot.fingerprint.clone(), + if target_case_sensitive { + "case-sensitive".into() + } else { + "case-insensitive".into() + }, + ]; + plan_values.extend(missing.iter().cloned()); + Ok(ScanPlan { + operation_id: request.operation_id.clone(), + source_fingerprint: source_snapshot.fingerprint, + target_fingerprint: target_snapshot.fingerprint, + target_case_sensitive, + plan_fingerprint: fingerprint(&plan_values), + missing, + skipped_links: source_snapshot.skipped_links, + }) +} + +fn selected_with_parents(plan: &ScanPlan, requested: &[String]) -> Vec { + let missing: HashSet<_> = plan.missing.iter().cloned().collect(); + let mut selected = HashSet::new(); + for path in requested { + if !missing.contains(path) { + continue; + } + let parts: Vec<_> = path.split('/').collect(); + for depth in 1..=parts.len() { + let parent = parts[..depth].join("/"); + if missing.contains(&parent) { + selected.insert(parent); + } + } + } + let mut selected: Vec<_> = selected.into_iter().collect(); + selected.sort_by(|left, right| { + left.matches('/') + .count() + .cmp(&right.matches('/').count()) + .then(left.cmp(right)) + }); + selected +} + +pub fn apply_plan( + request: &ScanRequest, + plan: &ScanPlan, + selected: &[String], + token: &CancellationToken, +) -> Result { + let started_at = timestamp(); + let current = scan_plan(request, token)?; + if current.source_fingerprint != plan.source_fingerprint + || current.target_fingerprint != plan.target_fingerprint + || current.plan_fingerprint != plan.plan_fingerprint + || current.missing != plan.missing + { + return Err(NativeError::new( + NativeErrorCode::StalePlan, + "The folders changed after review. Scan again before applying.", + )); + } + let target = canonical_directory(&request.target_path, "target")?; + let mut directories = Vec::new(); + for relative in selected_with_parents(plan, selected) { + token.check()?; + if relative + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(NativeError::new( + NativeErrorCode::InvalidPath, + "A plan contains an invalid relative path.", + )); + } + let destination = relative + .split('/') + .fold(target.clone(), |path, part| path.join(part)); + assert_no_link_below(&target, &destination)?; + let entry = match fs::create_dir(&destination) { + Ok(()) => DirectoryResult { + relative_path: relative, + status: DirectoryStatus::Created, + error: None, + }, + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists && destination.is_dir() => + { + DirectoryResult { + relative_path: relative, + status: DirectoryStatus::AlreadyExists, + error: None, + } + } + Err(error) => DirectoryResult { + relative_path: relative, + status: DirectoryStatus::Failed, + error: Some(error.to_string()), + }, + }; + directories.push(entry); + } + Ok(ApplyResult { + run_id: Uuid::new_v4().to_string(), + started_at, + finished_at: timestamp(), + directories, + }) +} + +fn timestamp() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .to_string() + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub id: String, + pub name: String, + pub source_path: String, + pub target_path: String, + pub exclusions: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutboxMutation { + pub mutation_id: String, + pub kind: String, + pub payload: String, + pub occurred_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunRecord { + pub id: String, + pub profile_id: String, + pub status: String, + pub created_count: i64, + pub result_json: String, +} + +pub struct Database(Mutex); + +const MIGRATIONS: &[(i64, &str)] = &[(1, include_str!("../migrations/0001_offline_state.sql"))]; + +impl Database { + pub fn open(path: impl AsRef) -> Result { + let mut connection = Connection::open(path)?; + connection.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);", + )?; + let current_version: i64 = connection.query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", + [], + |row| row.get(0), + )?; + for (version, sql) in MIGRATIONS + .iter() + .filter(|(version, _)| *version > current_version) + { + let transaction = connection.transaction()?; + transaction.execute_batch(sql)?; + transaction.execute( + "INSERT INTO schema_migrations(version) VALUES (?1)", + [version], + )?; + transaction.commit()?; + } + Ok(Self(Mutex::new(connection))) + } + + fn connection(&self) -> std::sync::MutexGuard<'_, Connection> { + self.0.lock().expect("database mutex poisoned") + } + + pub fn device_id(&self) -> Result { + let connection = self.connection(); + if let Some(id) = connection + .query_row( + "SELECT value FROM settings WHERE key = 'device_id'", + [], + |row| row.get(0), + ) + .optional()? + { + return Ok(id); + } + let id = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO settings(key, value) VALUES ('device_id', ?1)", + [&id], + )?; + Ok(id) + } + + pub fn save_profile(&self, profile: &Profile) -> Result<(), NativeError> { + let payload = serde_json::to_string(profile) + .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let mut connection = self.connection(); + let transaction = connection.transaction()?; + transaction.execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, source_path=excluded.source_path, + target_path=excluded.target_path, exclusions_json=excluded.exclusions_json, updated_at=excluded.updated_at", + params![profile.id, profile.name, profile.source_path, profile.target_path, + serde_json::to_string(&profile.exclusions).map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?, + profile.created_at, profile.updated_at], + )?; + transaction.execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, 'upsert', ?2, ?3)", + params![Uuid::new_v4().to_string(), payload, profile.updated_at], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn list_profiles(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT id, name, source_path, target_path, exclusions_json, created_at, updated_at + FROM profiles ORDER BY updated_at DESC, id ASC", + )?; + let rows = statement.query_map([], |row| { + let exclusions: String = row.get(4)?; + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + source_path: row.get(2)?, + target_path: row.get(3)?, + exclusions: serde_json::from_str(&exclusions).unwrap_or_default(), + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + pub fn delete_profile(&self, id: &str) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + transaction.execute("DELETE FROM profiles WHERE id = ?1", [id])?; + transaction.execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, 'delete', ?2, ?3)", + params![Uuid::new_v4().to_string(), json!({ "profileId": id }).to_string(), timestamp()], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn enqueue_mutation( + &self, + id: &str, + kind: &str, + payload: &str, + occurred_at: &str, + ) -> Result<(), NativeError> { + self.connection().execute( + "INSERT OR IGNORE INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, ?2, ?3, ?4)", + params![id, kind, payload, occurred_at], + )?; + Ok(()) + } + + pub fn pending_outbox(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT mutation_id, kind, payload, occurred_at FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(OutboxMutation { + mutation_id: row.get(0)?, + kind: row.get(1)?, + payload: row.get(2)?, + occurred_at: row.get(3)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + + pub fn acknowledge_mutations(&self, ids: &[String]) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + for id in ids { + transaction.execute("DELETE FROM mutation_outbox WHERE mutation_id = ?1", [id])?; + } + transaction.commit()?; + Ok(()) + } + + pub fn set_sync_cursor(&self, epoch: &str, cursor: &str) -> Result<(), NativeError> { + self.connection().execute( + "INSERT INTO sync_state(singleton, epoch, cursor) VALUES (1, ?1, ?2) + ON CONFLICT(singleton) DO UPDATE SET epoch=excluded.epoch, cursor=excluded.cursor", + params![epoch, cursor], + )?; + Ok(()) + } + + pub fn sync_cursor(&self) -> Result, NativeError> { + Ok(self + .connection() + .query_row( + "SELECT epoch, cursor FROM sync_state WHERE singleton = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?) + } + + pub fn record_run( + &self, + id: &str, + profile_id: &str, + status: &str, + created_count: i64, + result_json: &str, + ) -> Result<(), NativeError> { + let mut connection = self.connection(); + let transaction = connection.transaction()?; + transaction.execute( + "INSERT INTO run_history(id, profile_id, status, created_count, result_json) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, profile_id, status, created_count, result_json], + )?; + transaction.execute( + "DELETE FROM run_history WHERE sequence NOT IN (SELECT sequence FROM run_history ORDER BY sequence DESC LIMIT 100)", + [], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn run_history(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT id, profile_id, status, created_count, result_json FROM run_history ORDER BY sequence DESC LIMIT 100", + )?; + let rows = statement.query_map([], |row| { + Ok(RunRecord { + id: row.get(0)?, + profile_id: row.get(1)?, + status: row.get(2)?, + created_count: row.get(3)?, + result_json: row.get(4)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } +} + +#[tauri::command] +fn choose_folder(_role: String) -> Option { + rfd::FileDialog::new() + .pick_folder() + .map(|path| path.to_string_lossy().into_owned()) +} + +#[tauri::command] +async fn scan_directories( + request: ScanRequest, + operations: State<'_, Arc>, +) -> Result { + let operation_id = request.operation_id.clone(); + let token = operations.begin(&operation_id); + let spawned = tauri::async_runtime::spawn_blocking(move || scan_plan(&request, &token)).await; + operations.finish(&operation_id); + spawned.map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))? +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApplyCommand { + request: ScanRequest, + plan: ScanPlan, + selected: Vec, + profile_id: Option, +} + +#[tauri::command] +async fn apply_directories( + command: ApplyCommand, + operations: State<'_, Arc>, + database: State<'_, Database>, +) -> Result { + let operation_id = command.request.operation_id.clone(); + let token = operations.begin(&operation_id); + let spawned = tauri::async_runtime::spawn_blocking(move || { + apply_plan(&command.request, &command.plan, &command.selected, &token) + .map(|result| (result, command.profile_id)) + }) + .await; + operations.finish(&operation_id); + let result = + spawned.map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let (result, profile_id) = result?; + let created = result + .directories + .iter() + .filter(|entry| entry.status == DirectoryStatus::Created) + .count() as i64; + let payload = serde_json::to_string(&result.directories) + .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + database.record_run( + &result.run_id, + profile_id.as_deref().unwrap_or(""), + "completed", + created, + &payload, + )?; + Ok(result) +} + +#[tauri::command] +fn cancel_operation(operation_id: String, operations: State<'_, Arc>) { + operations.cancel(&operation_id); +} + +#[tauri::command] +fn list_profiles(database: State<'_, Database>) -> Result, NativeError> { + database.list_profiles() +} + +#[tauri::command] +fn save_profile(profile: Profile, database: State<'_, Database>) -> Result { + database.save_profile(&profile)?; + Ok(profile) +} + +#[tauri::command] +fn delete_profile(id: String, database: State<'_, Database>) -> Result<(), NativeError> { + database.delete_profile(&id) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .setup(|app| { + let directory = app.path().app_data_dir()?; + fs::create_dir_all(&directory)?; + let database = Database::open(directory.join("rootline.sqlite3")) + .map_err(|error| Box::::from(error.to_string()))?; + database + .device_id() + .map_err(|error| Box::::from(error.to_string()))?; + app.manage(database); + app.manage(Arc::new(OperationRegistry::default())); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + choose_folder, + scan_directories, + apply_directories, + cancel_operation, + list_profiles, + save_profile, + delete_profile, + ]) + .run(tauri::generate_context!()) + .expect("error while running Rootline"); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..5e8c789 --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + rootline_desktop::run(); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..c3e78c8 --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Rootline by baole.space", + "version": "2.0.0", + "identifier": "space.baole.rootline", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://127.0.0.1:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Rootline by baole.space", + "width": 1240, + "height": 800, + "minWidth": 880, + "minHeight": 620, + "resizable": true + } + ], + "security": { "csp": null } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [], + "category": "Utility", + "shortDescription": "Safe, additive folder-structure synchronization" + } +} diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs new file mode 100644 index 0000000..acf0c31 --- /dev/null +++ b/apps/desktop/src-tauri/tests/native.rs @@ -0,0 +1,162 @@ +use std::fs; + +use rootline_desktop::{ + apply_plan, scan_plan, CancellationToken, Database, NativeErrorCode, Profile, ScanRequest, +}; +use tempfile::tempdir; + +fn request(source: &std::path::Path, target: &std::path::Path) -> ScanRequest { + ScanRequest { + operation_id: "scan-1".into(), + source_path: source.to_path_buf(), + target_path: target.to_path_buf(), + exclusions: vec![".git".into()], + } +} + +#[test] +fn scans_additively_and_revalidates_before_mkdir() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir_all(source.path().join("src/components")).unwrap(); + fs::create_dir_all(source.path().join(".git/objects")).unwrap(); + + let cancellation = CancellationToken::default(); + let plan = scan_plan(&request(source.path(), target.path()), &cancellation).unwrap(); + assert_eq!(plan.missing, ["docs", "docs/api", "src", "src/components"]); + + let result = apply_plan( + &request(source.path(), target.path()), + &plan, + &["docs/api".into()], + &cancellation, + ) + .unwrap(); + assert!(target.path().join("docs/api").is_dir()); + assert_eq!(result.directories.len(), 2); + + fs::create_dir(source.path().join("changed-after-review")).unwrap(); + let error = apply_plan( + &request(source.path(), target.path()), + &plan, + &plan.missing, + &cancellation, + ) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::StalePlan); +} + +#[test] +fn rejects_overlapping_roots_and_honors_cancellation() { + let source = tempdir().unwrap(); + fs::create_dir(source.path().join("child")).unwrap(); + let overlap = request(source.path(), &source.path().join("child")); + assert_eq!( + scan_plan(&overlap, &CancellationToken::default()) + .unwrap_err() + .code, + NativeErrorCode::PathOverlap, + ); + + let target = tempdir().unwrap(); + let cancelled = CancellationToken::default(); + cancelled.cancel(); + assert_eq!( + scan_plan(&request(source.path(), target.path()), &cancelled) + .unwrap_err() + .code, + NativeErrorCode::Cancelled, + ); +} + +#[cfg(unix)] +#[test] +fn skips_symbolic_links_instead_of_following_them() { + use std::os::unix::fs::symlink; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(outside.path().join("secret")).unwrap(); + symlink(outside.path(), source.path().join("linked")).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert!(plan.missing.is_empty()); + assert_eq!(plan.skipped_links, ["linked"]); +} + +#[test] +fn migrates_and_persists_offline_state_with_bounded_history() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("rootline.sqlite3")).unwrap(); + let device_id = database.device_id().unwrap(); + assert_eq!(database.device_id().unwrap(), device_id); + + let profile = Profile { + id: "profile-1".into(), + name: "Work".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![".git".into()], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + assert_eq!(database.list_profiles().unwrap(), [profile]); + let saved_mutations = database.pending_outbox().unwrap(); + assert_eq!(saved_mutations.len(), 1); + assert_eq!(saved_mutations[0].kind, "upsert"); + database + .acknowledge_mutations(&[saved_mutations[0].mutation_id.clone()]) + .unwrap(); + + database + .enqueue_mutation( + "mutation-1", + "upsert", + "{\"id\":\"profile-1\"}", + "2026-08-15T00:00:00Z", + ) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database + .acknowledge_mutations(&["mutation-1".into()]) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + database.set_sync_cursor("epoch-1", "cursor-7").unwrap(); + assert_eq!( + database.sync_cursor().unwrap(), + Some(("epoch-1".into(), "cursor-7".into())) + ); + + for index in 0..105 { + database + .record_run( + &format!("run-{index:03}"), + "profile-1", + "completed", + index, + "[]", + ) + .unwrap(); + } + let history = database.run_history().unwrap(); + assert_eq!(history.len(), 100); + assert_eq!(history.first().unwrap().id, "run-104"); + assert_eq!(history.last().unwrap().id, "run-005"); + + database.delete_profile("profile-1").unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + let deleted_mutations = database.pending_outbox().unwrap(); + assert_eq!(deleted_mutations.len(), 1); + assert_eq!(deleted_mutations[0].kind, "delete"); + drop(database); + + let reopened = Database::open(directory.path().join("rootline.sqlite3")).unwrap(); + assert_eq!(reopened.device_id().unwrap(), device_id); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 0000000..f7d962f --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,344 @@ +import { useEffect, useId, useRef, useState } from "react"; + +import { DiffTree } from "./components/DiffTree"; +import { copy, type Locale } from "./i18n"; +import { + nativeFailure, + tauriGateway, + type ApplyResult, + type NativeGateway, + type Profile, + type ScanPlan, + type ScanRequest, +} from "./native"; + +type Step = "choose" | "scanning" | "review" | "applying" | "result"; +type RebindRole = "source" | "target" | null; + +interface AppProps { + gateway?: NativeGateway; + initialProfile?: Profile; +} + +const defaultExclusions = [".git", ".svn", ".hg", "node_modules", ".DS_Store", "Thumbs.db", "dist", "build"]; + +function operationId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function Mark(): React.JSX.Element { + return ( + + ); +} + +export function App({ gateway = tauriGateway, initialProfile }: AppProps): React.JSX.Element { + const [locale, setLocale] = useState("en"); + const text = copy[locale]; + const [profiles, setProfiles] = useState(initialProfile ? [initialProfile] : []); + const [activeProfile, setActiveProfile] = useState(initialProfile); + const [profileName, setProfileName] = useState(initialProfile?.name ?? ""); + const [sourcePath, setSourcePath] = useState(initialProfile?.sourcePath ?? ""); + const [targetPath, setTargetPath] = useState(initialProfile?.targetPath ?? ""); + const [step, setStep] = useState("choose"); + const [plan, setPlan] = useState(); + const [selected, setSelected] = useState>(new Set()); + const [result, setResult] = useState(); + const [error, setError] = useState(); + const [rebind, setRebind] = useState(null); + const [activeOperation, setActiveOperation] = useState(); + const headingRef = useRef(null); + const scanButtonRef = useRef(null); + const profileNameId = useId(); + + useEffect(() => { + if (initialProfile) return; + let live = true; + void gateway.listProfiles().then((loaded) => { + if (live) setProfiles(loaded); + }).catch(() => { + // A first-run database failure is surfaced when the user saves; choosing folders still works. + }); + return () => { live = false; }; + }, [gateway, initialProfile]); + + useEffect(() => { + if (step !== "choose") headingRef.current?.focus(); + }, [step]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || (step !== "review" && step !== "result")) return; + event.preventDefault(); + setStep("choose"); + setTimeout(() => scanButtonRef.current?.focus(), 0); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [step]); + + const choose = async (role: "source" | "target"): Promise => { + const path = await gateway.chooseFolder({ role }); + if (!path) return; + if (role === "source") setSourcePath(path); + else setTargetPath(path); + setRebind(null); + setError(undefined); + setStep("choose"); + }; + + const makeRequest = (id: string): ScanRequest => ({ + operationId: id, + sourcePath, + targetPath, + exclusions: activeProfile?.exclusions ?? defaultExclusions, + }); + + const scan = async (): Promise => { + const id = operationId("scan"); + setError(undefined); + setRebind(null); + setActiveOperation(id); + setStep("scanning"); + try { + const nextPlan = await gateway.scan(makeRequest(id)); + setPlan(nextPlan); + setSelected(new Set(nextPlan.missing)); + setStep("review"); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + if (failure.code === "CANCELLED") { + setStep("choose"); + } else if (failure.code === "SOURCE_NOT_FOUND") { + setRebind("source"); + setError(text.sourceMissing); + setStep("choose"); + } else if (failure.code === "TARGET_NOT_FOUND") { + setRebind("target"); + setError(text.targetMissing); + setStep("choose"); + } else { + setError(text.genericError); + setStep("choose"); + } + } finally { + setActiveOperation(undefined); + } + }; + + const apply = async (): Promise => { + if (!plan) return; + const id = operationId("apply"); + setActiveOperation(id); + setError(undefined); + setStep("applying"); + try { + const nextResult = await gateway.apply({ + request: makeRequest(id), + plan: { ...plan, operationId: id }, + selected: [...selected], + ...(activeProfile ? { profileId: activeProfile.id } : {}), + }); + setResult(nextResult); + setStep("result"); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + setError(failure.code === "STALE_PLAN" ? failure.message : text.genericError); + setStep("review"); + } finally { + setActiveOperation(undefined); + } + }; + + const cancel = async (): Promise => { + if (activeOperation) await gateway.cancel(activeOperation); + }; + + const selectProfile = (profile: Profile): void => { + setActiveProfile(profile); + setProfileName(profile.name); + setSourcePath(profile.sourcePath); + setTargetPath(profile.targetPath); + setPlan(undefined); + setResult(undefined); + setError(undefined); + setStep("choose"); + }; + + const onProfileKeyDown = (event: React.KeyboardEvent, index: number): void => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : -1; + const next = (index + delta + profiles.length) % profiles.length; + document.querySelector(`[data-profile-index="${next}"]`)?.focus(); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectProfile(profiles[index]!); + } + }; + + const newProfile = (): void => { + setActiveProfile(undefined); + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + setStep("choose"); + }; + + const saveProfile = async (): Promise => { + const now = new Date().toISOString(); + const profile: Profile = { + id: activeProfile?.id ?? operationId("profile"), + name: profileName.trim() || `${text.source} → ${text.target}`, + sourcePath, + targetPath, + exclusions: activeProfile?.exclusions ?? defaultExclusions, + createdAt: activeProfile?.createdAt ?? now, + updatedAt: now, + }; + const saved = await gateway.saveProfile(profile); + setProfiles((current) => [saved, ...current.filter((entry) => entry.id !== saved.id)]); + setActiveProfile(saved); + setProfileName(saved.name); + }; + + const createdCount = result?.directories.filter((entry) => entry.status === "created").length ?? 0; + const currentStep = step === "choose" ? 0 : step === "scanning" ? 1 : step === "review" ? 2 : 3; + + return ( +

+ + +
+
+ + +
+ +
+ {step === "choose" ? ( +
+

STRUCTURE / ADDITIVE

+

{text.chooseTitle}

+

{text.chooseBody}

+ {error ?

{error}

: null} +
+
+ 01
{text.source}{sourcePath || text.notChosen}
+ +
+ +
+ 02
{text.target}{targetPath || text.notChosen}
+ +
+
+
+ + setProfileName(event.currentTarget.value)} /> + +
+ +
+ ) : null} + + {step === "scanning" || step === "applying" ? ( +
+
+ ) : null} + + {step === "review" && plan ? ( +
+

DIFF / {plan.targetCaseSensitive ? "CASE-SENSITIVE" : "CASE-INSENSITIVE"}

+

{text.review(plan.missing.length)}

+

{text.reviewBody}

+ {error ?

{error}

: null} + {plan.skippedLinks.length ?

{text.skipped(plan.skippedLinks.length)}

: null} + {plan.missing.length === 0 ? ( +

{text.empty}

{text.emptyBody}

+ ) : ( + + )} +
+ + {plan.missing.length ? : null} +
+
+ ) : null} + + {step === "result" && result ? ( +
+ +

RUN / COMPLETE

+

{text.result(createdCount)}

+

{text.resultBody}

+
+
{createdCount}{text.created}
+
{result.directories.filter((entry) => entry.status === "already-exists").length}{text.unchanged}
+
{result.directories.filter((entry) => entry.status === "failed").length}{text.failed}
+
+
+ + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/apps/desktop/src/components/DiffTree.tsx b/apps/desktop/src/components/DiffTree.tsx new file mode 100644 index 0000000..f67bc66 --- /dev/null +++ b/apps/desktop/src/components/DiffTree.tsx @@ -0,0 +1,169 @@ +import { memo, useDeferredValue, useMemo, useRef, useState } from "react"; + +interface DiffTreeProps { + entries: readonly string[]; + selected: ReadonlySet; + onSelectionChange: (selection: Set) => void; + labels?: { + search: string; + all: string; + selected: string; + clear: string; + selectAll: string; + expand: string; + collapse: string; + }; +} + +const ROW_HEIGHT = 38; +const VIEWPORT_HEIGHT = 456; +const OVERSCAN = 8; + +const defaultLabels = { + search: "Search folders", + all: "All", + selected: "Selected", + clear: "Clear selection", + selectAll: "Select all", + expand: "Expand", + collapse: "Collapse", +}; + +function depthOf(path: string): number { + return path.split("/").length; +} + +function parentPaths(entries: readonly string[]): Set { + const parents = new Set(); + const entrySet = new Set(entries); + for (const entry of entries) { + const parts = entry.split("/"); + for (let depth = 1; depth < parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (entrySet.has(parent)) parents.add(parent); + } + } + return parents; +} + +function visibleUnderExpansion(entry: string, expanded: ReadonlySet, parents: ReadonlySet): boolean { + const parts = entry.split("/"); + for (let depth = 1; depth < parts.length; depth += 1) { + const parent = parts.slice(0, depth).join("/"); + if (parents.has(parent) && !expanded.has(parent)) return false; + } + return true; +} + +export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionChange, labels = defaultLabels }: DiffTreeProps) { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()); + const [filter, setFilter] = useState<"all" | "selected">("all"); + const parents = useMemo(() => parentPaths(entries), [entries]); + const [expanded, setExpanded] = useState>(() => new Set(parents)); + const [scrollTop, setScrollTop] = useState(0); + const viewport = useRef(null); + + const visible = useMemo(() => entries.filter((entry) => { + if (filter === "selected" && !selected.has(entry)) return false; + if (deferredQuery && !entry.toLocaleLowerCase().includes(deferredQuery)) return false; + return visibleUnderExpansion(entry, expanded, parents); + }), [deferredQuery, entries, expanded, filter, parents, selected]); + + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const end = Math.min(visible.length, Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ROW_HEIGHT) + OVERSCAN); + const windowed = visible.slice(start, end); + + const toggleSelection = (entry: string): void => { + const descendants = entries.filter((candidate) => candidate === entry || candidate.startsWith(`${entry}/`)); + const next = new Set(selected); + const shouldSelect = descendants.some((candidate) => !next.has(candidate)); + for (const descendant of descendants) { + if (shouldSelect) next.add(descendant); + else next.delete(descendant); + } + onSelectionChange(next); + }; + + const toggleExpanded = (entry: string): void => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(entry)) next.delete(entry); + else next.add(entry); + return next; + }); + }; + + return ( +
+
+ +
+ + +
+ + +
+
+ {visible.length.toLocaleString()} folders + {selected.size.toLocaleString()} selected +
+
setScrollTop(event.currentTarget.scrollTop)} + > +
+ {windowed.map((entry, offset) => { + const index = start + offset; + const isParent = parents.has(entry); + const isExpanded = expanded.has(entry); + return ( +
+ {isParent ? ( + + ) :
+ ); + })} +
+
+
+ ); +}); diff --git a/apps/desktop/src/i18n.ts b/apps/desktop/src/i18n.ts new file mode 100644 index 0000000..5578f18 --- /dev/null +++ b/apps/desktop/src/i18n.ts @@ -0,0 +1,96 @@ +export type Locale = "en" | "vi"; + +export const copy = { + en: { + localeButton: "Tiếng Việt", + profiles: "Profiles", + newProfile: "New profile", + noProfiles: "No saved profiles", + offline: "Offline workspace", + chooseTitle: "Choose two roots", + chooseBody: "Rootline compares directory structure only. Files stay untouched.", + source: "Source", + target: "Target", + chooseSource: "Choose source folder", + chooseTarget: "Choose target folder", + rebindSource: "Rebind source", + rebindTarget: "Rebind target", + notChosen: "Not chosen", + scan: "Scan differences", + scanning: "Tracing folder structure…", + cancel: "Cancel", + review: (count: number) => `Review ${count.toLocaleString()} missing folders`, + reviewBody: "Only selected missing directories will be created. Files and existing folders are never removed.", + empty: "The target already has this structure.", + emptyBody: "Nothing will be changed. Choose another pair or scan again later.", + apply: "Create selected folders", + applying: "Creating folders…", + result: (count: number) => `${count.toLocaleString()} folders created`, + resultBody: "Rootline finished the additive run. Existing content was left in place.", + created: "created", + unchanged: "unchanged", + failed: "failed", + again: "Run again", + newPair: "Choose another pair", + sourceMissing: "The saved source is no longer available. Choose a new source folder to continue.", + targetMissing: "The saved target is no longer available. Choose a new target folder to continue.", + genericError: "Rootline could not complete this operation. Check folder access and try again.", + save: "Save profile", + profileName: "Profile name", + all: "All", + selected: "Selected", + clear: "Clear selection", + selectAll: "Select all", + search: "Search folders", + expand: "Expand", + collapse: "Collapse", + skipped: (count: number) => `${count.toLocaleString()} linked folders skipped for safety`, + steps: ["Choose", "Scan", "Review", "Apply"], + }, + vi: { + localeButton: "English", + profiles: "Hồ sơ", + newProfile: "Hồ sơ mới", + noProfiles: "Chưa có hồ sơ đã lưu", + offline: "Không gian ngoại tuyến", + chooseTitle: "Chọn hai thư mục gốc", + chooseBody: "Rootline chỉ so sánh cấu trúc thư mục. Tệp luôn được giữ nguyên.", + source: "Nguồn", + target: "Đích", + chooseSource: "Chọn thư mục nguồn", + chooseTarget: "Chọn thư mục đích", + rebindSource: "Chọn lại nguồn", + rebindTarget: "Chọn lại đích", + notChosen: "Chưa chọn", + scan: "Quét khác biệt", + scanning: "Đang lần theo cấu trúc thư mục…", + cancel: "Hủy", + review: (count: number) => `Xem lại ${count.toLocaleString()} thư mục còn thiếu`, + reviewBody: "Chỉ các thư mục còn thiếu đã chọn mới được tạo. Tệp và thư mục hiện có không bao giờ bị xóa.", + empty: "Thư mục đích đã có cấu trúc này.", + emptyBody: "Không có gì thay đổi. Hãy chọn cặp khác hoặc quét lại sau.", + apply: "Tạo các thư mục đã chọn", + applying: "Đang tạo thư mục…", + result: (count: number) => `Đã tạo ${count.toLocaleString()} thư mục`, + resultBody: "Rootline đã hoàn tất lượt bổ sung. Nội dung hiện có được giữ nguyên.", + created: "đã tạo", + unchanged: "không đổi", + failed: "thất bại", + again: "Chạy lại", + newPair: "Chọn cặp khác", + sourceMissing: "Nguồn đã lưu không còn khả dụng. Hãy chọn thư mục nguồn mới để tiếp tục.", + targetMissing: "Đích đã lưu không còn khả dụng. Hãy chọn thư mục đích mới để tiếp tục.", + genericError: "Rootline không thể hoàn tất thao tác. Hãy kiểm tra quyền truy cập thư mục và thử lại.", + save: "Lưu hồ sơ", + profileName: "Tên hồ sơ", + all: "Tất cả", + selected: "Đã chọn", + clear: "Bỏ chọn", + selectAll: "Chọn tất cả", + search: "Tìm thư mục", + expand: "Mở rộng", + collapse: "Thu gọn", + skipped: (count: number) => `Đã bỏ qua ${count.toLocaleString()} thư mục liên kết để an toàn`, + steps: ["Chọn", "Quét", "Xem lại", "Áp dụng"], + }, +} as const; diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index cb0ff5c..218eab4 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -1 +1,3 @@ -export {}; +export { App } from "./App"; +export { DiffTree } from "./components/DiffTree"; +export type { NativeGateway, Profile, ScanPlan } from "./native"; diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..90558d3 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Rootline mount point is missing."); + +createRoot(root).render(); diff --git a/apps/desktop/src/native.ts b/apps/desktop/src/native.ts new file mode 100644 index 0000000..d08d1af --- /dev/null +++ b/apps/desktop/src/native.ts @@ -0,0 +1,75 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface Profile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + createdAt: string; + updatedAt: string; +} + +export interface ScanRequest { + operationId: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; +} + +export interface ScanPlan { + operationId: string; + sourceFingerprint: string; + targetFingerprint: string; + targetCaseSensitive: boolean; + planFingerprint: string; + missing: string[]; + skippedLinks: string[]; +} + +export type DirectoryStatus = "created" | "already-exists" | "failed"; + +export interface ApplyResult { + runId: string; + startedAt: string; + finishedAt: string; + directories: Array<{ relativePath: string; status: DirectoryStatus; error?: string }>; +} + +export interface NativeGateway { + chooseFolder(input: { role: "source" | "target" }): Promise; + scan(request: ScanRequest): Promise; + apply(input: { request: ScanRequest; plan: ScanPlan; selected: string[]; profileId?: string }): Promise; + cancel(operationId: string): Promise; + listProfiles(): Promise; + saveProfile(profile: Profile): Promise; + deleteProfile(id: string): Promise; +} + +export const tauriGateway: NativeGateway = { + chooseFolder: ({ role }) => invoke("choose_folder", { role }), + scan: (request) => invoke("scan_directories", { request }), + apply: (command) => invoke("apply_directories", { command }), + cancel: (operationId) => invoke("cancel_operation", { operationId }), + listProfiles: () => invoke("list_profiles"), + saveProfile: (profile) => invoke("save_profile", { profile }), + deleteProfile: (id) => invoke("delete_profile", { id }), +}; + +export interface NativeFailure { + code: string; + message: string; + details?: Record; +} + +export function nativeFailure(error: unknown): NativeFailure { + if (typeof error === "object" && error !== null && "code" in error) { + const value = error as Partial; + return { + code: typeof value.code === "string" ? value.code : "INTERNAL", + message: typeof value.message === "string" ? value.message : "Unexpected native error.", + ...(value.details ? { details: value.details } : {}), + }; + } + return { code: "INTERNAL", message: error instanceof Error ? error.message : "Unexpected native error." }; +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css new file mode 100644 index 0000000..692eb52 --- /dev/null +++ b/apps/desktop/src/styles.css @@ -0,0 +1,90 @@ +:root { + color-scheme: light dark; + font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif; + font-synthesis: none; + --graphite-950: #111619; + --graphite-900: #171c1f; + --graphite-800: #232a2e; + --graphite-700: #354046; + --fog-50: #f5f7f6; + --fog-100: #e8edeb; + --fog-300: #b8c1bd; + --cyan-500: #23bac7; + --cyan-600: #1299a6; + --moss-500: #587b64; + --amber-500: #d69b3b; + --danger: #c7584f; + background: var(--fog-50); + color: var(--graphite-900); +} + +* { box-sizing: border-box; } +html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .45; } +:focus-visible { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 3px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 264px minmax(0, 1fr); background: var(--fog-50); } +.profile-rail { display: flex; flex-direction: column; min-height: 100vh; padding: 26px 18px 18px; color: #f5f7f6; background: #171c1f; border-right: 1px solid #ffffff14; } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 6px 34px; } +.brand-mark { width: 38px; height: 38px; overflow: visible; } +.brand-mark path { fill: none; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; } +.mark-soft { stroke: var(--fog-100); }.profile-rail .mark-soft { stroke: #e8edeb; }.mark-line { stroke: var(--cyan-500); }.mark-moss { fill: var(--moss-500); }.mark-amber { fill: var(--amber-500); } +.brand strong, .brand span { display: block; }.brand strong { color: #f5f7f6; font-size: 18px; letter-spacing: -.02em; }.brand span { color: #b8c1bd; font: 10px/1.4 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .08em; } +.rail-label { display: flex; justify-content: space-between; padding: 0 8px 9px; color: #b8c1bd; font: 600 10px/1.3 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .12em; text-transform: uppercase; } +.profile-list { display: grid; gap: 4px; } +.profile-item, .new-profile { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; } +.profile-item { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; align-items: center; padding: 11px 10px; border-radius: 9px; } +.profile-item:hover, .profile-item[aria-selected="true"] { background: #ffffff0d; } +.profile-item[aria-selected="true"] { box-shadow: inset 2px 0 var(--cyan-500); } +.profile-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--graphite-700); }.profile-item[aria-selected="true"] .profile-dot { background: var(--cyan-500); } +.profile-item strong, .profile-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.profile-item strong { font-size: 13px; }.profile-item small { margin-top: 3px; color: #b8c1bd; font-size: 10px; } +.new-profile { margin-top: 8px; padding: 10px; color: #b8c1bd; font-size: 12px; }.new-profile span { margin-right: 8px; color: var(--cyan-500); } +.rail-footer { display: flex; align-items: center; gap: 8px; margin-top: auto; padding: 14px 8px 0; border-top: 1px solid #ffffff14; color: #b8c1bd; font-size: 11px; }.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--moss-500); box-shadow: 0 0 0 3px #587b6428; } + +.workbench { min-width: 0; min-height: 100vh; background-image: linear-gradient(#20272a0b 1px, transparent 1px), linear-gradient(90deg, #20272a0b 1px, transparent 1px); background-size: 32px 32px; } +.topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 28px; padding: 0 42px; background: color-mix(in srgb, var(--fog-50) 92%, transparent); border-bottom: 1px solid var(--fog-100); } +.steps { display: flex; align-items: center; gap: 0; padding: 0; margin: 0; list-style: none; } +.steps li { display: flex; align-items: center; gap: 8px; color: #6d7773; font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } +.steps li:not(:last-child)::after { content: ""; width: clamp(18px, 4vw, 58px); height: 1px; margin: 0 10px; background: var(--fog-300); } +.steps li.reached { color: var(--graphite-900); }.steps li span { display: grid; place-items: center; width: 23px; height: 23px; border: 1px solid var(--fog-300); border-radius: 50%; font: 600 10px/1 ui-monospace, monospace; }.steps li.reached span { border-color: var(--cyan-500); background: var(--cyan-500); color: var(--graphite-950); } +.locale-button, .quiet-button, .secondary-button { border: 1px solid var(--fog-300); color: var(--graphite-900); background: var(--fog-50); border-radius: 7px; } +.locale-button { padding: 7px 10px; font: 600 11px/1 ui-monospace, monospace; } +.workspace { width: min(100%, 1100px); margin: 0 auto; padding: 54px clamp(28px, 5vw, 76px) 70px; } +.stage { min-height: calc(100vh - 202px); } +.eyebrow { margin: 0 0 15px; color: var(--cyan-600); font: 700 10px/1.3 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .15em; } +h1 { max-width: 760px; margin: 0; color: var(--graphite-950); font-size: clamp(36px, 5.5vw, 68px); font-weight: 520; line-height: .98; letter-spacing: -.055em; } +.lede { max-width: 650px; margin: 20px 0 38px; color: #59635f; font-size: 15px; line-height: 1.65; } +.root-pair { display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr); align-items: stretch; gap: 12px; } +.root-card { position: relative; min-height: 184px; display: flex; flex-direction: column; padding: 22px; overflow: hidden; border: 1px solid var(--fog-300); border-radius: 12px; background: color-mix(in srgb, var(--fog-50) 94%, white); box-shadow: 0 18px 40px #20272a0a; }.root-card.needs-rebind { border-color: var(--amber-500); } +.root-number { position: absolute; top: 14px; right: 16px; color: #9da7a2; font: 11px/1 ui-monospace, monospace; }.root-copy { min-width: 0; margin-top: 18px; }.root-copy small, .root-copy strong { display: block; }.root-copy small { margin-bottom: 9px; color: #727c77; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }.root-copy strong { overflow: hidden; color: var(--graphite-900); font: 600 13px/1.5 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; } +.root-card button { align-self: flex-start; margin-top: auto; padding: 8px 11px; border: 0; border-bottom: 1px solid var(--graphite-700); color: var(--graphite-800); background: transparent; font-size: 12px; }.flow-arrow { display: grid; place-items: center; color: var(--cyan-600); font: 24px/1 ui-monospace, monospace; } +.profile-save { display: grid; grid-template-columns: auto minmax(150px, 1fr) auto; align-items: center; gap: 12px; margin-top: 17px; padding: 13px 15px; border: 1px solid var(--fog-100); background: #ffffff80; border-radius: 9px; }.profile-save label { color: #66706c; font-size: 11px; }.profile-save input { min-width: 0; padding: 8px 10px; border: 1px solid var(--fog-300); border-radius: 6px; color: var(--graphite-900); background: var(--fog-50); } +.primary-button, .secondary-button { min-height: 44px; padding: 10px 16px; font-weight: 700; font-size: 12px; } +.primary-button { display: inline-flex; align-items: center; justify-content: center; gap: 18px; margin-top: 22px; border: 1px solid var(--graphite-950); border-radius: 7px; color: var(--fog-50); background: var(--graphite-950); box-shadow: inset 3px 0 var(--cyan-500); }.primary-button span { color: var(--cyan-500); font: 700 12px/1 ui-monospace, monospace; }.secondary-button { background: transparent; } +.error-card { display: flex; gap: 12px; align-items: flex-start; max-width: 720px; margin: -12px 0 24px; padding: 13px 15px; border-left: 3px solid var(--amber-500); color: var(--graphite-900); background: #d69b3b17; }.error-card > span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: var(--graphite-950); background: var(--amber-500); font-weight: 800; }.error-card p { margin: 0; line-height: 1.5; } + +.progress-stage { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }.progress-stage h1 { max-width: 600px; font-size: clamp(32px, 5vw, 58px); }.progress-status { margin: 18px 0 28px; color: #68716d; }.scan-figure { position: relative; width: 240px; height: 150px; margin-bottom: 40px; overflow: hidden; }.scan-trunk { position: absolute; inset: 10px 28px; border-left: 2px solid var(--fog-300); }.scan-trunk::before, .scan-trunk i { content: ""; position: absolute; left: 0; width: 120px; height: 1px; background: var(--fog-300); }.scan-trunk::before { top: 0; }.scan-trunk i:nth-child(1) { top: 30px; width: 170px; }.scan-trunk i:nth-child(2) { top: 60px; width: 105px; }.scan-trunk i:nth-child(3) { top: 90px; width: 150px; }.scan-trunk i:nth-child(4) { top: 120px; width: 75px; }.scan-line { position: absolute; top: 0; bottom: 0; left: 28px; width: 3px; background: var(--cyan-500); box-shadow: 0 0 20px #23bac7aa; animation: rootline-scan 1.5s cubic-bezier(.65,0,.35,1) infinite alternate; } +@keyframes rootline-scan { from { transform: translateX(0); } to { transform: translateX(178px); } } + +.review-stage h1 { font-size: clamp(34px, 4.8vw, 58px); }.review-stage .lede { margin-bottom: 24px; }.safety-note { display: inline-block; margin: 0 0 16px; padding: 6px 9px; color: #6d5623; background: #d69b3b1c; font: 600 10px/1.3 ui-monospace, monospace; } +.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; border-bottom: 1px solid #20272a09; content-visibility: auto; }.tree-row:hover { background: #23bac70a; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; padding: 0; border: 0; color: var(--cyan-600); background: transparent; font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.tree-check { min-width: 0; display: flex; align-items: center; gap: 9px; cursor: pointer; }.tree-check input { accent-color: var(--cyan-600); }.folder-glyph { width: 14px; height: 10px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } + +.result-stage { max-width: 760px; padding-top: 60px; }.result-mark { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 34px; border-radius: 50%; color: var(--fog-50); background: var(--moss-500); font-size: 26px; }.result-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 35px; border-block: 1px solid var(--fog-300); }.result-summary div { display: flex; align-items: baseline; gap: 9px; padding: 20px 14px; }.result-summary div + div { border-left: 1px solid var(--fog-300); }.result-summary strong { font: 500 30px/1 ui-monospace, monospace; }.result-summary span { color: #69736f; font-size: 11px; } + +@media (prefers-color-scheme: dark) { + :root { --fog-50: #171c1f; --fog-100: #252c30; --fog-300: #49545a; --graphite-950: #f0f4f2; --graphite-900: #e5ebe8; --graphite-800: #cdd6d2; background: #111619; color: #e5ebe8; } + .app-shell, .workbench { background-color: #111619; }.profile-rail { background: #0d1113; }.topbar { background: #171c1fed; }.lede, .progress-status, .empty-state p, .tree-summary, .steps li, .result-summary span { color: #aeb8b3; }.root-card, .diff-tree { background: #171c1f; }.profile-save, .empty-state { background: #171c1f80; }.profile-save input, .search-field, .locale-button, .quiet-button, .secondary-button { background: #111619; color: #e5ebe8; }.root-copy small, .profile-save label { color: #aeb8b3; } +} + +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; }.profile-rail { min-height: auto; padding: 14px 18px; }.brand { padding-bottom: 12px; }.rail-label, .profile-list { display: none; }.new-profile { position: absolute; top: 22px; right: 18px; width: auto; }.rail-footer { margin-top: 0; padding-top: 8px; border: 0; }.topbar { padding: 0 22px; }.workspace { padding-top: 38px; }.stage { min-height: auto; }.tree-tools { flex-wrap: wrap; } +} +@media (max-width: 620px) { + .steps li { font-size: 0; }.steps li:not(:last-child)::after { width: 12px; margin: 0 5px; }.topbar { min-height: 64px; }.root-pair { grid-template-columns: 1fr; }.flow-arrow { transform: rotate(90deg); }.profile-save { grid-template-columns: 1fr; }.tree-tools { align-items: stretch; }.search-field { flex-basis: 100%; }.review-actions { flex-direction: column-reverse; }.review-actions button { width: 100%; }.result-summary { grid-template-columns: 1fr; }.result-summary div + div { border-left: 0; border-top: 1px solid var(--fog-300); } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } +} diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx new file mode 100644 index 0000000..8ac41b0 --- /dev/null +++ b/apps/desktop/src/test/App.test.tsx @@ -0,0 +1,132 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import axe from "axe-core"; +import { describe, expect, test, vi } from "vitest"; + +import { App } from "../App"; +import { DiffTree } from "../components/DiffTree"; +import type { NativeGateway, ScanPlan } from "../native"; + +const plan: ScanPlan = { + operationId: "scan-1", + sourceFingerprint: "source-fp", + targetFingerprint: "target-fp", + targetCaseSensitive: false, + planFingerprint: "plan-fp", + missing: ["docs", "docs/api", "src", "src/components"], + skippedLinks: [], +}; + +function gateway(overrides: Partial = {}): NativeGateway { + return { + chooseFolder: vi.fn(async ({ role }) => role === "source" ? "/projects/source" : "/projects/target"), + scan: vi.fn(async () => plan), + apply: vi.fn(async () => ({ + runId: "run-1", + startedAt: "2026-08-15T00:00:00Z", + finishedAt: "2026-08-15T00:00:01Z", + directories: [ + { relativePath: "docs", status: "created" as const }, + { relativePath: "docs/api", status: "created" as const }, + ], + })), + cancel: vi.fn(async () => undefined), + listProfiles: vi.fn(async () => []), + saveProfile: vi.fn(async (profile) => profile), + deleteProfile: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("Rootline desktop workflow", () => { + test("moves from choosing roots through scan, review, and apply results", async () => { + const user = userEvent.setup(); + const native = gateway(); + render(); + + expect(screen.getByRole("heading", { name: "Choose two roots" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Choose source folder" })); + await user.click(screen.getByRole("button", { name: "Choose target folder" })); + expect(screen.getByText("/projects/source")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Scan differences" })); + const reviewHeading = await screen.findByRole("heading", { name: "Review 4 missing folders" }); + expect(reviewHeading).toHaveFocus(); + await user.click(screen.getByRole("checkbox", { name: "docs/api" })); + await user.click(screen.getByRole("button", { name: "Create selected folders" })); + + const resultHeading = await screen.findByRole("heading", { name: "2 folders created" }); + expect(resultHeading).toHaveFocus(); + expect(native.apply).toHaveBeenCalledWith(expect.objectContaining({ selected: ["docs", "src", "src/components"] })); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.getByRole("button", { name: "Scan differences" })).toHaveFocus()); + }); + + test("shows actionable loading, empty, failure, and rebind states", async () => { + const user = userEvent.setup(); + let resolveScan: ((value: ScanPlan) => void) | undefined; + const pending = new Promise((resolve) => { resolveScan = resolve; }); + const native = gateway({ scan: vi.fn(() => pending) }); + const view = render(); + await user.click(screen.getByRole("button", { name: "Choose source folder" })); + await user.click(screen.getByRole("button", { name: "Choose target folder" })); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + expect(screen.getByRole("status")).toHaveTextContent("Tracing folder structure"); + await act(async () => resolveScan?.({ ...plan, missing: [] })); + expect(await screen.findByText("The target already has this structure.")).toBeInTheDocument(); + + view.unmount(); + const failing = gateway({ scan: vi.fn(async () => { throw { code: "SOURCE_NOT_FOUND", message: "gone" }; }) }); + render(); + await user.click(screen.getByRole("button", { name: "Scan differences" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Choose a new source folder"); + expect(screen.getByRole("button", { name: "Rebind source" })).toBeInTheDocument(); + }); + + test("supports keyboard profile navigation, restores focus, Vietnamese copy, and has no serious axe violations", async () => { + const user = userEvent.setup(); + const native = gateway({ + listProfiles: vi.fn(async () => [ + { id: "one", name: "One", sourcePath: "/one", targetPath: "/target-one", exclusions: [], createdAt: "x", updatedAt: "x" }, + { id: "two", name: "Two", sourcePath: "/two", targetPath: "/target-two", exclusions: [], createdAt: "x", updatedAt: "x" }, + ]), + }); + const { container } = render(); + const first = await screen.findByRole("option", { name: "One" }); + first.focus(); + await user.keyboard("{ArrowDown}{Enter}"); + expect(screen.getByText("/two")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + expect(screen.getByRole("heading", { name: "Chọn hai thư mục gốc" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Quét khác biệt" })); + await user.click(await screen.findByRole("button", { name: "Tạo các thư mục đã chọn" })); + expect(await screen.findByText("đã tạo")).toBeInTheDocument(); + expect(screen.getByText("không đổi")).toBeInTheDocument(); + expect(screen.getByText("thất bại")).toBeInTheDocument(); + + const results = await axe.run(container); + expect(results.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious")).toEqual([]); + }); +}); + +describe("DiffTree virtualization", () => { + test("renders a bounded window for a 50,000-folder fixture and keeps subtree selection", async () => { + const user = userEvent.setup(); + const entries = Array.from({ length: 50_000 }, (_, index) => `root/group-${Math.floor(index / 100)}/folder-${index}`); + const onSelectionChange = vi.fn(); + const { container } = render( + , + ); + + expect(screen.getByRole("tree")).toHaveAttribute("aria-rowcount", "50000"); + expect(container.querySelectorAll('[role="treeitem"]').length).toBeLessThan(80); + await user.type(screen.getByRole("searchbox", { name: "Search folders" }), "folder-49999"); + expect(await screen.findByRole("checkbox", { name: entries[49_999]! })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Clear selection" })); + expect(onSelectionChange).toHaveBeenLastCalledWith(new Set()); + }); +}); diff --git a/apps/desktop/src/test/setup.ts b/apps/desktop/src/test/setup.ts new file mode 100644 index 0000000..623b10f --- /dev/null +++ b/apps/desktop/src/test/setup.ts @@ -0,0 +1,23 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { + value: () => null, +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 5285d28..e793ff2 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -1,8 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": ["src"] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 0000000..dc4ec91 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { port: 1420, strictPort: true }, +}); diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 0000000..444f151 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + }, +}); diff --git a/package.json b/package.json index c4bbb0b..16e587b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "build": "pnpm -r --if-present run build", - "test": "pnpm --filter @rootline/contracts test", + "test": "pnpm -r --if-present run test", "typecheck": "pnpm -r --if-present run typecheck" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbfd06d..4323d7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,11 +16,55 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3) + version: 3.2.7(@types/node@24.13.3)(jsdom@26.1.0) apps/api: {} - apps/desktop: {} + apps/desktop: + dependencies: + '@tauri-apps/api': + specifier: ^2.8.0 + version: 2.11.1 + react: + specifier: ^19.1.1 + version: 19.2.8 + react-dom: + specifier: ^19.1.1 + version: 19.2.8(react@19.2.8) + devDependencies: + '@tauri-apps/cli': + specifier: ^2.8.4 + version: 2.11.4 + '@testing-library/jest-dom': + specifier: ^6.8.0 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.4(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^19.1.10 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)) + axe-core: + specifier: ^4.10.3 + version: 4.13.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + vite: + specifier: ^7.1.2 + version: 7.3.6(@types/node@24.13.3) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jsdom@26.1.0) packages/cli: dependencies: @@ -41,6 +85,127 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -197,9 +362,22 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -207,6 +385,9 @@ packages: os: [linux] libc: [glibc] + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -345,6 +526,132 @@ packages: cpu: [x64] os: [win32] + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -357,6 +664,20 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -386,14 +707,50 @@ packages: '@vitest/utils@3.2.7': resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -402,6 +759,23 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -411,10 +785,30 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + electron-to-chromium@1.5.406: + resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -423,6 +817,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -444,15 +842,78 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -461,6 +922,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -479,11 +950,56 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -497,9 +1013,16 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -522,6 +1045,21 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -530,6 +1068,12 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -603,13 +1147,200 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -688,11 +1419,30 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -768,6 +1518,113 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -781,6 +1638,26 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.6(@types/node@24.13.3) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -823,10 +1700,36 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} + axe-core@4.13.0: {} + + baseline-browser-mapping@2.11.14: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.14 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.406 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + cac@6.7.14: {} + caniuse-lite@1.0.30001809: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -837,12 +1740,40 @@ snapshots: check-error@2.1.3: {} + convert-source-map@2.0.0: {} + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + electron-to-chromium@1.5.406: {} + + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.28.2: @@ -874,6 +1805,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -887,18 +1820,97 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.18: {} + node-releases@2.0.53: {} + + nwsapi@2.2.24: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -913,6 +1925,30 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -945,6 +1981,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -953,10 +2001,16 @@ snapshots: std-env@3.10.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -972,10 +2026,30 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + typescript@5.9.3: {} undici-types@7.18.2: {} + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + vite-node@3.2.4(@types/node@24.13.3): dependencies: cac: 6.7.14 @@ -1009,7 +2083,7 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@3.2.7(@types/node@24.13.3): + vitest@3.2.7(@types/node@24.13.3)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -1036,6 +2110,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -1050,7 +2125,32 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + ws@8.21.3: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} From ed03914a28711edde53362a17e2e7bbd7d2c8d58 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 04:58:46 +0700 Subject: [PATCH 10/26] fix: address Rootline desktop review findings --- apps/desktop/src-tauri/src/lib.rs | 172 +++++++++++++++++------ apps/desktop/src-tauri/tests/native.rs | 101 ++++++++++++- apps/desktop/src/App.tsx | 55 ++++++-- apps/desktop/src/components/DiffTree.tsx | 169 ++++++++++++++-------- apps/desktop/src/i18n.ts | 44 ++++++ apps/desktop/src/native.ts | 3 + apps/desktop/src/styles.css | 12 +- apps/desktop/src/test/App.test.tsx | 114 ++++++++++++++- 8 files changed, 541 insertions(+), 129 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5b8b772..ca29f98 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -84,6 +84,10 @@ impl CancellationToken { Ok(()) } } + + fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } } #[derive(Default)] @@ -144,6 +148,8 @@ pub struct DirectoryResult { #[serde(rename_all = "camelCase")] pub struct ScanPlan { pub operation_id: String, + pub source_root: PathBuf, + pub target_root: PathBuf, pub source_fingerprint: String, pub target_fingerprint: String, pub target_case_sensitive: bool, @@ -159,6 +165,7 @@ pub struct ApplyResult { pub started_at: String, pub finished_at: String, pub directories: Vec, + pub cancelled: bool, } #[derive(Debug)] @@ -205,7 +212,7 @@ fn canonical_directory(path: &Path, role: &str) -> Result fn assert_not_link(path: &Path) -> Result<(), NativeError> { let absolute = absolute(path)?; match fs::symlink_metadata(&absolute) { - Ok(metadata) if metadata.file_type().is_symlink() => Err(NativeError::at( + Ok(metadata) if is_link_or_junction(&metadata) => Err(NativeError::at( NativeErrorCode::InvalidPath, "A synchronization root must not be a symbolic link or junction.", &absolute, @@ -220,6 +227,20 @@ fn assert_not_link(path: &Path) -> Result<(), NativeError> { } } +#[cfg(not(windows))] +fn is_link_or_junction(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +fn is_link_or_junction(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + fn assert_no_link_below(root: &Path, destination: &Path) -> Result<(), NativeError> { let relative = destination.strip_prefix(root).map_err(|_| { NativeError::at( @@ -376,7 +397,7 @@ fn scan_root( let metadata = fs::symlink_metadata(&path).map_err(|error| { NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), &path) })?; - if metadata.file_type().is_symlink() { + if is_link_or_junction(&metadata) { skipped_links.push(relative); } else if metadata.is_dir() { entries.push(relative); @@ -424,6 +445,8 @@ pub fn scan_plan( .cloned() .collect(); let mut plan_values = vec![ + source.to_string_lossy().into_owned(), + target.to_string_lossy().into_owned(), source_snapshot.fingerprint.clone(), target_snapshot.fingerprint.clone(), if target_case_sensitive { @@ -435,6 +458,8 @@ pub fn scan_plan( plan_values.extend(missing.iter().cloned()); Ok(ScanPlan { operation_id: request.operation_id.clone(), + source_root: source, + target_root: target, source_fingerprint: source_snapshot.fingerprint, target_fingerprint: target_snapshot.fingerprint, target_case_sensitive, @@ -476,6 +501,14 @@ pub fn apply_plan( token: &CancellationToken, ) -> Result { let started_at = timestamp(); + let source = canonical_directory(&request.source_path, "source")?; + let target = canonical_directory(&request.target_path, "target")?; + if source != plan.source_root || target != plan.target_root { + return Err(NativeError::new( + NativeErrorCode::StalePlan, + "The selected roots differ from the reviewed plan.", + )); + } let current = scan_plan(request, token)?; if current.source_fingerprint != plan.source_fingerprint || current.target_fingerprint != plan.target_fingerprint @@ -487,10 +520,11 @@ pub fn apply_plan( "The folders changed after review. Scan again before applying.", )); } - let target = canonical_directory(&request.target_path, "target")?; let mut directories = Vec::new(); for relative in selected_with_parents(plan, selected) { - token.check()?; + if token.is_cancelled() { + break; + } if relative .split('/') .any(|part| part.is_empty() || part == "." || part == "..") @@ -504,37 +538,39 @@ pub fn apply_plan( .split('/') .fold(target.clone(), |path, part| path.join(part)); assert_no_link_below(&target, &destination)?; - let entry = match fs::create_dir(&destination) { - Ok(()) => DirectoryResult { - relative_path: relative, - status: DirectoryStatus::Created, - error: None, - }, - Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists && destination.is_dir() => - { - DirectoryResult { - relative_path: relative, - status: DirectoryStatus::AlreadyExists, - error: None, - } - } - Err(error) => DirectoryResult { - relative_path: relative, - status: DirectoryStatus::Failed, - error: Some(error.to_string()), - }, - }; - directories.push(entry); + directories.push(create_directory_result(relative, &destination)); } Ok(ApplyResult { run_id: Uuid::new_v4().to_string(), started_at, finished_at: timestamp(), directories, + cancelled: token.is_cancelled(), }) } +fn create_directory_result(relative_path: String, destination: &Path) -> DirectoryResult { + match fs::create_dir(destination) { + Ok(()) => DirectoryResult { + relative_path, + status: DirectoryStatus::Created, + error: None, + }, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && destination.is_dir() => { + DirectoryResult { + relative_path, + status: DirectoryStatus::AlreadyExists, + error: None, + } + } + Err(error) => DirectoryResult { + relative_path, + status: DirectoryStatus::Failed, + error: Some(error.to_string()), + }, + } +} + fn timestamp() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -817,6 +853,38 @@ struct ApplyCommand { profile_id: Option, } +fn record_apply_result( + database: &Database, + result: &ApplyResult, + profile_id: Option<&str>, +) -> Result<(), NativeError> { + let created = result + .directories + .iter() + .filter(|entry| entry.status == DirectoryStatus::Created) + .count() as i64; + let payload = serde_json::to_string(&result.directories) + .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; + let status = if result.cancelled { + "cancelled" + } else if result + .directories + .iter() + .any(|entry| entry.status == DirectoryStatus::Failed) + { + "partial" + } else { + "completed" + }; + database.record_run( + &result.run_id, + profile_id.unwrap_or(""), + status, + created, + &payload, + ) +} + #[tauri::command] async fn apply_directories( command: ApplyCommand, @@ -834,20 +902,7 @@ async fn apply_directories( let result = spawned.map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; let (result, profile_id) = result?; - let created = result - .directories - .iter() - .filter(|entry| entry.status == DirectoryStatus::Created) - .count() as i64; - let payload = serde_json::to_string(&result.directories) - .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; - database.record_run( - &result.run_id, - profile_id.as_deref().unwrap_or(""), - "completed", - created, - &payload, - )?; + record_apply_result(&database, &result, profile_id.as_deref())?; Ok(result) } @@ -899,3 +954,40 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running Rootline"); } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn mkdir_reports_already_existing_directories() { + let target = tempdir().unwrap(); + let existing = target.path().join("existing"); + fs::create_dir(&existing).unwrap(); + let result = create_directory_result("existing".into(), &existing); + assert_eq!(result.status, DirectoryStatus::AlreadyExists); + assert!(result.error.is_none()); + } + + #[test] + fn cancelled_apply_results_are_recorded_as_cancelled_history() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("history.sqlite3")).unwrap(); + let result = ApplyResult { + run_id: "cancelled-run".into(), + started_at: "x".into(), + finished_at: "y".into(), + directories: vec![DirectoryResult { + relative_path: "docs".into(), + status: DirectoryStatus::Created, + error: None, + }], + cancelled: true, + }; + record_apply_result(&database, &result, Some("profile-1")).unwrap(); + let history = database.run_history().unwrap(); + assert_eq!(history[0].status, "cancelled"); + assert_eq!(history[0].created_count, 1); + } +} diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index acf0c31..2a72c82 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -1,7 +1,8 @@ use std::fs; use rootline_desktop::{ - apply_plan, scan_plan, CancellationToken, Database, NativeErrorCode, Profile, ScanRequest, + apply_plan, detect_case_sensitive, scan_plan, CancellationToken, Database, DirectoryStatus, + NativeErrorCode, Profile, ScanRequest, }; use tempfile::tempdir; @@ -47,6 +48,92 @@ fn scans_additively_and_revalidates_before_mkdir() { assert_eq!(error.code, NativeErrorCode::StalePlan); } +#[test] +fn binds_a_plan_to_its_canonical_roots_even_when_snapshots_match() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let other_source = tempdir().unwrap(); + let other_target = tempdir().unwrap(); + fs::create_dir(source.path().join("docs")).unwrap(); + fs::create_dir(other_source.path().join("docs")).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(plan.source_root, fs::canonicalize(source.path()).unwrap()); + assert_eq!(plan.target_root, fs::canonicalize(target.path()).unwrap()); + + let error = apply_plan( + &request(other_source.path(), other_target.path()), + &plan, + &plan.missing, + &CancellationToken::default(), + ) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::StalePlan); + assert!(!other_target.path().join("docs").exists()); +} + +#[test] +fn reports_case_semantics_and_failed_directory_creation() { + let case_root = tempdir().unwrap(); + let case_sensitive = detect_case_sensitive(case_root.path()).unwrap(); + fs::write(case_root.path().join("CaseProbe"), b"x").unwrap(); + assert_eq!(case_root.path().join("caseprobe").exists(), !case_sensitive); + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir(source.path().join("blocked")).unwrap(); + fs::write(target.path().join("blocked"), b"not a directory").unwrap(); + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + let result = apply_plan( + &request(source.path(), target.path()), + &plan, + &["blocked".into()], + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(result.directories[0].status, DirectoryStatus::Failed); + assert!(result.directories[0].error.is_some()); +} + +#[test] +fn returns_accumulated_results_when_cancelled_during_mkdir() { + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + for index in 0..2_000 { + fs::create_dir(source.path().join(format!("folder-{index:04}"))).unwrap(); + } + let scan_request = request(source.path(), target.path()); + let plan = scan_plan(&scan_request, &CancellationToken::default()).unwrap(); + let token = CancellationToken::default(); + let worker_token = token.clone(); + let selected = plan.missing.clone(); + let worker = + std::thread::spawn(move || apply_plan(&scan_request, &plan, &selected, &worker_token)); + for _ in 0..10_000 { + if fs::read_dir(target.path()).unwrap().any(|entry| { + entry + .and_then(|value| value.file_type()) + .is_ok_and(|file_type| file_type.is_dir()) + }) { + token.cancel(); + break; + } + std::thread::yield_now(); + } + let result = worker.join().unwrap().unwrap(); + assert!(result.cancelled); + assert!(!result.directories.is_empty()); + assert!(result.directories.len() < 2_000); +} + #[test] fn rejects_overlapping_roots_and_honors_cancellation() { let source = tempdir().unwrap(); @@ -88,6 +175,18 @@ fn skips_symbolic_links_instead_of_following_them() { .unwrap(); assert!(plan.missing.is_empty()); assert_eq!(plan.skipped_links, ["linked"]); + + let linked_root = source.path().join("linked-root"); + symlink(outside.path(), &linked_root).unwrap(); + assert_eq!( + scan_plan( + &request(&linked_root, target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); } #[test] diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f7d962f..8d01be1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -54,6 +54,7 @@ export function App({ gateway = tauriGateway, initialProfile }: AppProps): React const [activeOperation, setActiveOperation] = useState(); const headingRef = useRef(null); const scanButtonRef = useRef(null); + const returnToScanRef = useRef(false); const profileNameId = useId(); useEffect(() => { @@ -68,15 +69,24 @@ export function App({ gateway = tauriGateway, initialProfile }: AppProps): React }, [gateway, initialProfile]); useEffect(() => { - if (step !== "choose") headingRef.current?.focus(); + if (step === "choose" && returnToScanRef.current) { + returnToScanRef.current = false; + scanButtonRef.current?.focus(); + } else { + headingRef.current?.focus(); + } }, [step]); + useEffect(() => { + document.documentElement.lang = locale; + }, [locale]); + useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== "Escape" || (step !== "review" && step !== "result")) return; event.preventDefault(); + returnToScanRef.current = true; setStep("choose"); - setTimeout(() => scanButtonRef.current?.focus(), 0); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); @@ -113,6 +123,7 @@ export function App({ gateway = tauriGateway, initialProfile }: AppProps): React } catch (unknownError) { const failure = nativeFailure(unknownError); if (failure.code === "CANCELLED") { + returnToScanRef.current = true; setStep("choose"); } else if (failure.code === "SOURCE_NOT_FOUND") { setRebind("source"); @@ -148,7 +159,13 @@ export function App({ gateway = tauriGateway, initialProfile }: AppProps): React setStep("result"); } catch (unknownError) { const failure = nativeFailure(unknownError); - setError(failure.code === "STALE_PLAN" ? failure.message : text.genericError); + setError( + failure.code === "STALE_PLAN" ? text.stalePlan + : failure.code === "SOURCE_NOT_FOUND" ? text.sourceMissing + : failure.code === "TARGET_NOT_FOUND" ? text.targetMissing + : failure.code === "CANCELLED" ? text.operationCancelled + : text.genericError, + ); setStep("review"); } finally { setActiveOperation(undefined); @@ -245,7 +262,7 @@ export function App({ gateway = tauriGateway, initialProfile }: AppProps): React
-
diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts new file mode 100644 index 0000000..2fd7220 --- /dev/null +++ b/apps/desktop/src/auth.ts @@ -0,0 +1,425 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { appDataDir, join } from "@tauri-apps/api/path"; +import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Stronghold, type Store } from "@tauri-apps/plugin-stronghold"; +import { + UserManager, + WebStorageStateStore, + type AsyncStorage, + type INavigator, + type IWindow, + type NavigateParams, + type NavigateResponse, + type User, + type UserManagerSettings, +} from "oidc-client-ts"; + +export const OIDC_SCOPE = "openid profile email permissions offline_access"; +export const OIDC_REDIRECT_URI = "rootline://auth/callback"; + +export interface OidcConfiguration { + authority: string; + clientId: string; + apiUrl: string; + redirectUri: typeof OIDC_REDIRECT_URI; + scope: typeof OIDC_SCOPE; +} + +export interface AuthViewUser { + sub: string; + name?: string; + email?: string; + permissions: string[]; +} + +export interface AuthSnapshot { + configured: boolean; + loading: boolean; + user: AuthViewUser | null; + dataVersion: number; + accountClaimRequired?: boolean; + epochResetRequired?: boolean; + error?: string; +} + +export interface AuthController { + snapshot(): AuthSnapshot; + subscribe(listener: (snapshot: AuthSnapshot) => void): () => void; + initialize(): Promise; + signIn(): Promise; + handleCallback(url: string): Promise; + signOut(removeLocalProfiles: boolean): Promise; + deleteAccountData(removeLocalProfiles: boolean): Promise; + resolveEpochReset(removeLocalProfiles: boolean): Promise; + resolveAccountClaim(uploadExisting: boolean): Promise; + sync(): Promise; + dispose(): void; +} + +interface Environment { + VITE_AUTHENTIK_ISSUER?: string; + VITE_AUTHENTIK_CLIENT_ID?: string; + VITE_ROOTLINE_SYNC_API?: string; +} + +export function readOidcConfiguration(environment: Environment): OidcConfiguration | null { + const values = [environment.VITE_AUTHENTIK_ISSUER, environment.VITE_AUTHENTIK_CLIENT_ID, environment.VITE_ROOTLINE_SYNC_API]; + if (values.every((value) => !value)) return null; + if (!environment.VITE_AUTHENTIK_ISSUER) throw new Error("VITE_AUTHENTIK_ISSUER is required when hosted sync is configured."); + if (!environment.VITE_AUTHENTIK_CLIENT_ID) throw new Error("VITE_AUTHENTIK_CLIENT_ID is required when hosted sync is configured."); + if (!environment.VITE_ROOTLINE_SYNC_API) throw new Error("VITE_ROOTLINE_SYNC_API is required when hosted sync is configured."); + const authority = new URL(environment.VITE_AUTHENTIK_ISSUER); + const api = new URL(environment.VITE_ROOTLINE_SYNC_API); + if (authority.protocol !== "https:" || api.protocol !== "https:") throw new Error("Hosted authentication and sync endpoints must use HTTPS."); + return { authority: authority.toString(), clientId: environment.VITE_AUTHENTIK_CLIENT_ID, apiUrl: api.toString().replace(/\/$/, ""), redirectUri: OIDC_REDIRECT_URI, scope: OIDC_SCOPE }; +} + +function callbackError(): Error { + return new Error("AUTH_CALLBACK_INVALID: Rootline rejected an unexpected authentication callback."); +} + +export function validateCallbackUrl(rawUrl: string, expectedState?: string): URL { + let url: URL; + try { url = new URL(rawUrl); } catch { throw callbackError(); } + if (url.protocol !== "rootline:" || url.hostname !== "auth" || url.pathname !== "/callback" || url.hash) throw callbackError(); + if (url.searchParams.getAll("code").length !== 1 || url.searchParams.getAll("state").length !== 1) throw callbackError(); + if (!url.searchParams.get("code") || !url.searchParams.get("state")) throw callbackError(); + if (expectedState !== undefined && url.searchParams.get("state") !== expectedState) throw callbackError(); + return url; +} + +interface ProjectableUser { + profile: { sub?: unknown; name?: unknown; email?: unknown; permissions?: unknown }; + expired?: boolean | undefined; + access_token?: unknown; + id_token?: unknown; + refresh_token?: unknown; +} + +export function projectUser(user: ProjectableUser): AuthViewUser | null { + if (user.expired || typeof user.profile.sub !== "string") return null; + const permissions = Array.isArray(user.profile.permissions) + ? user.profile.permissions.filter((value): value is string => typeof value === "string") + : []; + return { + sub: user.profile.sub, + ...(typeof user.profile.name === "string" ? { name: user.profile.name } : {}), + ...(typeof user.profile.email === "string" ? { email: user.profile.email } : {}), + permissions, + }; +} + +export class StrongholdAsyncStorage implements AsyncStorage { + private readonly encoder = new TextEncoder(); + private readonly decoder = new TextDecoder(); + private readonly opened = this.open(); + private static readonly INDEX = "__rootline_oidc_keys"; + + get length(): Promise { return this.keys().then((keys) => keys.length); } + private async open(): Promise<{ stronghold: Stronghold; store: Store }> { + const password = await invoke("auth_vault_password"); + const stronghold = await Stronghold.load(await join(await appDataDir(), "rootline-auth.stronghold"), password); + let client; + try { client = await stronghold.loadClient("rootline-oidc"); } + catch { client = await stronghold.createClient("rootline-oidc"); } + return { stronghold, store: client.getStore() }; + } + async clear(): Promise { + const { stronghold, store } = await this.opened; + for (const key of await this.keys()) await store.remove(key); + await store.remove(StrongholdAsyncStorage.INDEX); + await stronghold.save(); + } + async getItem(key: string): Promise { + const value = await (await this.opened).store.get(key); + return value ? this.decoder.decode(value) : null; + } + key(index: number): Promise { return this.keys().then((keys) => keys[index] ?? null); } + async removeItem(key: string): Promise { + const { stronghold, store } = await this.opened; + await store.remove(key); + await this.writeKeys((await this.keys()).filter((value) => value !== key)); + await stronghold.save(); + } + async setItem(key: string, value: string): Promise { + const { stronghold, store } = await this.opened; + await store.insert(key, Array.from(this.encoder.encode(value))); + const keys = await this.keys(); + if (!keys.includes(key)) await this.writeKeys([...keys, key].sort()); + await stronghold.save(); + } + private async writeKeys(keys: string[]): Promise { + await (await this.opened).store.insert(StrongholdAsyncStorage.INDEX, Array.from(this.encoder.encode(JSON.stringify(keys)))); + } + private async keys(): Promise { + const value = await (await this.opened).store.get(StrongholdAsyncStorage.INDEX); + if (!value) return []; + const parsed: unknown = JSON.parse(this.decoder.decode(value)); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; + } +} + +class SystemBrowserNavigator implements INavigator { + prepare(): Promise { + return Promise.resolve({ + navigate: async ({ url }: NavigateParams): Promise => { await openUrl(url); return { url }; }, + close: () => undefined, + }); + } + callback(): Promise { return Promise.resolve(); } +} + +export function createOidcSettings( + config: OidcConfiguration, + stateStore: WebStorageStateStore, + userStore: WebStorageStateStore, +): UserManagerSettings { + return { + authority: config.authority, + client_id: config.clientId, + redirect_uri: config.redirectUri, + post_logout_redirect_uri: config.redirectUri, + response_type: "code", + disablePKCE: false, + scope: config.scope, + stateStore, + userStore, + automaticSilentRenew: true, + revokeTokensOnSignout: true, + loadUserInfo: true, + }; +} + +export class DesktopAuthController implements AuthController { + private current: AuthSnapshot = { configured: true, loading: true, user: null, dataVersion: 0 }; + private readonly listeners = new Set<(snapshot: AuthSnapshot) => void>(); + private readonly manager: UserManager; + private unlisteners: UnlistenFn[] = []; + private resetEpoch: string | undefined; + private initialization = 0; + private initialized = false; + private initializationFlight: { generation: number; promise: Promise } | undefined; + private callbackQueue: Promise = Promise.resolve(); + + constructor(private readonly config: OidcConfiguration, manager?: UserManager) { + if (manager) { + this.manager = manager; + } else { + const storage = new StrongholdAsyncStorage(); + const stateStore = new WebStorageStateStore({ prefix: "rootline.oidc.state.", store: storage }); + const userStore = new WebStorageStateStore({ prefix: "rootline.oidc.user.", store: storage }); + const settings = createOidcSettings(config, stateStore, userStore); + this.manager = new UserManager(settings, new SystemBrowserNavigator()); + } + } + + snapshot(): AuthSnapshot { return this.current; } + subscribe(listener: (snapshot: AuthSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.current); + return () => this.listeners.delete(listener); + } + private update(value: AuthSnapshot): void { this.current = value; this.listeners.forEach((listener) => listener(value)); } + + initialize(): Promise { + if (this.initialized) return Promise.resolve(); + if (this.initializationFlight?.generation === this.initialization) return this.initializationFlight.promise; + const generation = ++this.initialization; + const promise = this.initializeOnce(generation).catch((error: unknown) => { + if (generation === this.initialization) { + this.update({ + ...this.current, + loading: false, + error: error instanceof Error ? error.message : "Secure account storage is unavailable.", + }); + } + throw error; + }).finally(() => { + if (this.initializationFlight?.generation === generation) this.initializationFlight = undefined; + }); + this.initializationFlight = { generation, promise }; + return promise; + } + + private async initializeOnce(generation: number): Promise { + const user = await this.manager.getUser(); + if (generation !== this.initialization) return; + this.update({ configured: true, loading: false, user: user ? projectUser(user) : null, dataVersion: this.current.dataVersion }); + let deepLink: UnlistenFn | undefined; + let singleInstance: UnlistenFn | undefined; + try { + deepLink = await onOpenUrl((urls) => { for (const url of urls) void this.handleCallback(url); }); + if (generation !== this.initialization) return; + singleInstance = await listen("rootline-auth-deep-link", (event) => { void this.handleCallback(event.payload); }); + if (generation !== this.initialization) return; + this.unlisteners.push(deepLink, singleInstance); + deepLink = undefined; + singleInstance = undefined; + this.initialized = true; + } finally { + deepLink?.(); + singleInstance?.(); + } + } + + async signIn(): Promise { + this.update({ ...this.current, loading: true }); + try { + await this.manager.signinRedirect({ nonce: crypto.randomUUID() }); + } catch (error) { + this.update({ + ...this.current, + loading: false, + error: error instanceof Error ? error.message : "The system browser could not be opened.", + }); + throw error; + } + } + + handleCallback(rawUrl: string): Promise { + const pending = this.callbackQueue.then(() => this.processCallback(rawUrl)); + this.callbackQueue = pending.catch(() => undefined); + return pending; + } + + private async processCallback(rawUrl: string): Promise { + try { + const url = validateCallbackUrl(rawUrl); + const user = await this.manager.signinRedirectCallback(url.toString()); + this.update({ configured: true, loading: false, user: projectUser(user), dataVersion: this.current.dataVersion }); + try { await this.sync(); } catch { /* sync() already surfaces reset-required; offline sign-in remains valid */ } + } catch (error) { + this.update({ + configured: true, + loading: false, + user: this.current.user, + dataVersion: this.current.dataVersion, + error: error instanceof Error ? error.message : "Authentication failed.", + }); + } + } + + async signOut(removeLocalProfiles: boolean): Promise { + try { await this.manager.revokeTokens(["access_token", "refresh_token"]); } catch { /* local sign-out must still complete offline */ } + await this.manager.removeUser(); + await invoke("disconnect_hosted_account", { removeLocalProfiles }); + this.resetEpoch = undefined; + this.update({ configured: true, loading: false, user: null, dataVersion: this.current.dataVersion + 1 }); + } + + async deleteAccountData(removeLocalProfiles: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || !user.access_token || typeof user.profile.sub !== "string") throw new Error("AUTH_REQUIRED"); + await invoke("delete_hosted_account_data", { + apiUrl: this.config.apiUrl, + accessToken: user.access_token, + subject: user.profile.sub, + removeLocalProfiles, + }); + this.update({ ...this.current, dataVersion: this.current.dataVersion + 1 }); + } + + async resolveEpochReset(removeLocalProfiles: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || typeof user.profile.sub !== "string" || !this.resetEpoch) throw new Error("AUTH_REQUIRED"); + await invoke("accept_hosted_epoch", { + subject: user.profile.sub, + epoch: this.resetEpoch, + removeLocalProfiles, + }); + this.resetEpoch = undefined; + this.update({ + configured: true, + loading: false, + user: projectUser(user), + dataVersion: this.current.dataVersion + 1, + }); + } + + async resolveAccountClaim(uploadExisting: boolean): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || typeof user.profile.sub !== "string") throw new Error("AUTH_REQUIRED"); + await invoke("claim_hosted_account", { subject: user.profile.sub, uploadExisting }); + this.update({ + configured: true, + loading: false, + user: projectUser(user), + dataVersion: this.current.dataVersion + 1, + }); + void this.sync().catch(() => undefined); + } + + async sync(): Promise { + const user = await this.manager.getUser(); + if (!user || user.expired || !user.access_token || typeof user.profile.sub !== "string") return; + try { + await invoke("sync_hosted_profiles", { apiUrl: this.config.apiUrl, accessToken: user.access_token, subject: user.profile.sub }); + this.update({ ...this.current, dataVersion: this.current.dataVersion + 1 }); + } catch (error) { + const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown } }; + if (value?.code === "SYNC_EPOCH_RESET_REQUIRED" && typeof value.details?.epoch === "string") { + this.resetEpoch = value.details.epoch; + this.update({ + ...this.current, + loading: false, + epochResetRequired: true, + error: typeof value.message === "string" ? value.message : "Hosted profile data was reset.", + }); + } else if (value?.code === "SYNC_ACCOUNT_CLAIM_REQUIRED") { + this.update({ + ...this.current, + loading: false, + accountClaimRequired: true, + error: "Choose whether this account may upload existing local profiles.", + }); + } + throw error; + } + } + + dispose(): void { + this.initialization += 1; + this.initialized = false; + this.initializationFlight = undefined; + this.unlisteners.splice(0).forEach((unlisten) => unlisten()); + } +} + +class LocalAuthController implements AuthController { + private readonly value: AuthSnapshot = { configured: false, loading: false, user: null, dataVersion: 0 }; + snapshot() { return this.value; } + subscribe(listener: (snapshot: AuthSnapshot) => void) { listener(this.value); return () => undefined; } + initialize() { return Promise.resolve(); } + signIn() { return Promise.resolve(); } + handleCallback() { return Promise.resolve(); } + signOut(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + deleteAccountData(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + resolveEpochReset(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } + resolveAccountClaim() { return Promise.resolve(); } + sync() { return Promise.resolve(); } + dispose() { /* nothing to release */ } +} + +export function createDesktopAuth(environment: Environment = { + VITE_AUTHENTIK_ISSUER: import.meta.env.VITE_AUTHENTIK_ISSUER, + VITE_AUTHENTIK_CLIENT_ID: import.meta.env.VITE_AUTHENTIK_CLIENT_ID, + VITE_ROOTLINE_SYNC_API: import.meta.env.VITE_ROOTLINE_SYNC_API, +}): AuthController { + const config = readOidcConfiguration(environment); + return config ? new DesktopAuthController(config) : new LocalAuthController(); +} + +export class ProfileSyncCoordinator { + private timer: ReturnType | undefined; + constructor(private readonly auth: AuthController, private readonly debounceMs = 750) {} + private async safeSync(): Promise { try { await this.auth.sync(); } catch { /* offline use remains unaffected */ } } + start(): Promise { return this.safeSync(); } + signedIn(): Promise { return this.safeSync(); } + manual(): Promise { return this.auth.sync(); } + profileEdited(): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { this.timer = undefined; void this.safeSync(); }, this.debounceMs); + } +} diff --git a/apps/desktop/src/components/AuthControls.tsx b/apps/desktop/src/components/AuthControls.tsx new file mode 100644 index 0000000..411f3d3 --- /dev/null +++ b/apps/desktop/src/components/AuthControls.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; + +import type { AuthController, AuthSnapshot, ProfileSyncCoordinator } from "../auth"; + +export function AuthControls({ auth, coordinator }: { auth: AuthController; coordinator?: ProfileSyncCoordinator }): React.JSX.Element | null { + const [snapshot, setSnapshot] = useState(() => auth.snapshot()); + const [choice, setChoice] = useState<"signout" | "delete" | "reset" | "claim" | null>(null); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(); + const [actionStatus, setActionStatus] = useState(); + + useEffect(() => { + const unsubscribe = auth.subscribe(setSnapshot); + void auth.initialize().then(() => coordinator?.start()).catch(() => undefined); + return () => { unsubscribe(); auth.dispose(); }; + }, [auth, coordinator]); + + const run = async (action: () => Promise): Promise => { + setBusy(true); + setActionError(undefined); + setActionStatus(undefined); + try { await action(); setChoice(null); setActionStatus("Account action completed."); } + catch (error) { setActionError(error instanceof Error ? error.message : "Account action failed."); } + finally { setBusy(false); } + }; + + if (!snapshot.configured) return null; + if (snapshot.loading) return Connecting account…; + if (!snapshot.user) return
{actionError ?? snapshot.error ? {actionError ?? snapshot.error} : null}
; + + return ( +
+ {snapshot.user.name ?? snapshot.user.email ?? "Signed in"} + {snapshot.accountClaimRequired ? <>Choose whether this account may upload existing local profiles. : null} + {snapshot.epochResetRequired ? <>Hosted data was reset. Review local profiles before reconnecting. : null} + {actionError ?? snapshot.error ? {actionError ?? snapshot.error} : null} + {busy || actionStatus ? {busy ? "Working…" : actionStatus} : null} + + + + {choice ? ( +
+

{choice === "signout" ? "Choose what Rootline keeps on this device." : choice === "delete" ? "Hosted profiles will be permanently deleted and the sync epoch will rotate." : choice === "reset" ? "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded." : "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account."}

+ + + +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 90558d3..2cd780e 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,9 +2,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { createDesktopAuth, ProfileSyncCoordinator } from "./auth"; import "./styles.css"; const root = document.getElementById("root"); if (!root) throw new Error("Rootline mount point is missing."); -createRoot(root).render(); +const auth = createDesktopAuth(); +const syncCoordinator = new ProfileSyncCoordinator(auth); + +createRoot(root).render(); diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 9ee22af..ab860d6 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -23,6 +23,11 @@ html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } button, input { font: inherit; } button { cursor: pointer; } button:disabled { cursor: not-allowed; opacity: .45; } +.topbar-actions, .auth-controls { display: flex; align-items: center; gap: 8px; } +.auth-controls { position: relative; font-size: 11px; } +.auth-controls button { min-height: 30px; padding: 5px 8px; border: 1px solid var(--fog-300); border-radius: 5px; background: var(--fog-50); } +.auth-choice { position: absolute; z-index: 20; top: calc(100% + 8px); right: 0; width: 290px; padding: 14px; border: 1px solid var(--fog-300); border-radius: 8px; background: var(--fog-50); box-shadow: 0 16px 45px #20272a22; } +.auth-choice p { margin: 0 0 10px; line-height: 1.45; } :focus-visible { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 3px; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx index 760de5e..64de033 100644 --- a/apps/desktop/src/test/App.test.tsx +++ b/apps/desktop/src/test/App.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, test, vi } from "vitest"; import { App } from "../App"; import { DiffTree } from "../components/DiffTree"; import type { NativeGateway, ScanPlan } from "../native"; +import type { AuthController, AuthSnapshot } from "../auth"; const plan: ScanPlan = { operationId: "scan-1", @@ -191,6 +192,29 @@ describe("Rootline desktop workflow", () => { const results = await axe.run(container); expect(results.violations).toEqual([]); }); + + test("reconciles visible profiles after hosted sync or destructive cleanup commits", async () => { + const user = userEvent.setup(); + const profile = { id: "remote", name: "Remote", sourcePath: "/private/path", targetPath: "/target", exclusions: [], createdAt: "x", updatedAt: "x" }; + const listProfiles = vi.fn().mockResolvedValueOnce([profile]).mockResolvedValueOnce([]); + const listeners = new Set<(snapshot: AuthSnapshot) => void>(); + let state: AuthSnapshot = { configured: true, loading: false, dataVersion: 0, user: { sub: "alice", permissions: ["rootline:profiles:sync"] } }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listeners.add(listener); listener(state); return () => listeners.delete(listener); }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + }; + render(); + await user.click(await screen.findByRole("option", { name: "Remote" })); + expect(screen.getByText("/private/path")).toBeInTheDocument(); + state = { ...state, dataVersion: 1 }; + listeners.forEach((listener) => listener(state)); + await waitFor(() => expect(screen.queryByRole("option", { name: "Remote" })).not.toBeInTheDocument()); + expect(screen.queryByText("/private/path")).not.toBeInTheDocument(); + }); }); describe("DiffTree virtualization", () => { diff --git a/apps/desktop/src/test/AuthControls.test.tsx b/apps/desktop/src/test/AuthControls.test.tsx new file mode 100644 index 0000000..fc3a48c --- /dev/null +++ b/apps/desktop/src/test/AuthControls.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; + +import { AuthControls } from "../components/AuthControls"; +import type { AuthController, AuthSnapshot } from "../auth"; + +test("sign-out explicitly keeps or removes local synced profiles without rendering tokens", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { configured: true, loading: false, user: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, dataVersion: 0 }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), + signIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + dispose: vi.fn(), + }; + const view = render(); + expect(view.container.textContent).not.toMatch(/access_token|refresh_token|bearer/i); + await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.signOut).toHaveBeenCalledWith(false); + await user.click(screen.getByRole("button", { name: "Sign out" })); + await user.click(screen.getByRole("button", { name: "Remove local profiles" })); + expect(auth.signOut).toHaveBeenCalledWith(true); + + await user.click(screen.getByRole("button", { name: "Delete hosted data" })); + expect(screen.getByRole("dialog", { name: "Delete hosted data options" })).toHaveTextContent(/epoch will rotate/i); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.deleteAccountData).toHaveBeenCalledWith(false); + + await user.click(screen.getByRole("button", { name: "Delete hosted data" })); + await user.click(screen.getByRole("button", { name: "Remove local profiles" })); + expect(auth.deleteAccountData).toHaveBeenCalledWith(true); +}); + +test("requires an explicit keep-or-remove decision after an epoch reset", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, epochResetRequired: true, + user: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth: AuthController = { + snapshot: () => state, + subscribe: (listener) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + }; + render(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Review reset" })); + expect(screen.getByRole("dialog", { name: "Hosted reset options" })).toHaveTextContent(/stale queued changes will be discarded/i); + await user.click(screen.getByRole("button", { name: "Keep local profiles" })); + expect(auth.resolveEpochReset).toHaveBeenCalledWith(false); +}); + +test("requires explicit consent before existing absolute-path profiles are claimed by an account", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, accountClaimRequired: true, + user: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + render(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Review local profiles" })); + expect(screen.getByRole("dialog", { name: "Local profile upload options" })).toHaveTextContent(/absolute paths/i); + await user.click(screen.getByRole("button", { name: "Keep local only" })); + expect(auth.resolveAccountClaim).toHaveBeenCalledWith(false); +}); diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts new file mode 100644 index 0000000..3769420 --- /dev/null +++ b/apps/desktop/src/test/auth.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { OidcClient, WebStorageStateStore, type AsyncStorage } from "oidc-client-ts"; + +import { + DesktopAuthController, + OIDC_SCOPE, + ProfileSyncCoordinator, + createOidcSettings, + projectUser, + readOidcConfiguration, + validateCallbackUrl, + type AuthController, + type OidcConfiguration, +} from "../auth"; + +const tauriMocks = vi.hoisted(() => ({ + invoke: vi.fn(), + listen: vi.fn(async () => vi.fn()), + onOpenUrl: vi.fn(async () => vi.fn()), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: tauriMocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: tauriMocks.listen })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ onOpenUrl: tauriMocks.onOpenUrl })); + +class MemoryAsyncStorage implements AsyncStorage { + private readonly values = new Map(); + get length() { return Promise.resolve(this.values.size); } + clear() { this.values.clear(); return Promise.resolve(); } + getItem(key: string) { return Promise.resolve(this.values.get(key) ?? null); } + key(index: number) { return Promise.resolve([...this.values.keys()][index] ?? null); } + removeItem(key: string) { this.values.delete(key); return Promise.resolve(); } + setItem(key: string, value: string) { this.values.set(key, value); return Promise.resolve(); } +} + +describe("Rootline desktop authentication boundary", () => { + const config = { + authority: "https://auth.baole.space/application/o/rootline/", + clientId: "rootline-desktop", + apiUrl: "https://rootline-api.baole.space", + redirectUri: "rootline://auth/callback" as const, + scope: OIDC_SCOPE, + } satisfies OidcConfiguration; + + beforeEach(() => { + tauriMocks.invoke.mockReset(); + tauriMocks.listen.mockReset().mockResolvedValue(vi.fn()); + tauriMocks.onOpenUrl.mockReset().mockResolvedValue(vi.fn()); + }); + + test("uses the public PKCE client scopes and fails closed on partial configuration", () => { + expect(OIDC_SCOPE).toBe("openid profile email permissions offline_access"); + expect(readOidcConfiguration({})).toBeNull(); + expect(() => readOidcConfiguration({ VITE_AUTHENTIK_ISSUER: "https://auth.baole.space/application/o/rootline/" })) + .toThrow(/CLIENT_ID/); + expect(readOidcConfiguration({ + VITE_AUTHENTIK_ISSUER: "https://auth.baole.space/application/o/rootline/", + VITE_AUTHENTIK_CLIENT_ID: "rootline-desktop", + VITE_ROOTLINE_SYNC_API: "https://rootline-api.baole.space", + })).toEqual(expect.objectContaining({ + redirectUri: "rootline://auth/callback", + scope: OIDC_SCOPE, + })); + }); + + test("accepts only the exact callback scheme, host, path, state, and code", () => { + expect(validateCallbackUrl("rootline://auth/callback?code=abc&state=expected", "expected").toString()) + .toBe("rootline://auth/callback?code=abc&state=expected"); + for (const value of [ + "https://auth/callback?code=abc&state=expected", + "rootline://evil/callback?code=abc&state=expected", + "rootline://auth/callback/extra?code=abc&state=expected", + "rootline://auth/callback?code=abc&state=wrong", + "rootline://auth/callback?state=expected", + "rootline://auth/callback?code=abc&state=expected&code=second", + ]) expect(() => validateCallbackUrl(value, "expected")).toThrow(/AUTH_CALLBACK_INVALID/); + }); + + test("creates an actual PKCE+nonce request and consumes callback state only once outside browser storage", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + const storage = new MemoryAsyncStorage(); + const stateStore = new WebStorageStateStore({ prefix: "test.state.", store: storage }); + const userStore = new WebStorageStateStore({ prefix: "test.user.", store: storage }); + const authority = "https://auth.baole.space/application/o/rootline/"; + const settings = createOidcSettings({ + authority, clientId: "rootline-desktop", apiUrl: "https://rootline-api.baole.space", + redirectUri: "rootline://auth/callback", scope: OIDC_SCOPE, + }, stateStore, userStore); + const client = new OidcClient({ + ...settings, + metadata: { + issuer: authority, + authorization_endpoint: `${authority}authorize/`, + token_endpoint: `${authority}token/`, + }, + }); + const request = await client.createSigninRequest({ nonce: "nonce-must-match-id-token" }); + const url = new URL(request.url); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(url.searchParams.get("nonce")).toBe("nonce-must-match-id-token"); + expect(browserWrite).not.toHaveBeenCalled(); + + const state = url.searchParams.get("state")!; + await expect(client.readSigninResponseState(`rootline://auth/callback?code=x&state=wrong`, true)).rejects.toThrow(/state/i); + const accepted = await client.readSigninResponseState(`rootline://auth/callback?code=x&state=${state}`, true); + expect(accepted.state).toEqual(expect.objectContaining({ nonce: url.searchParams.get("nonce"), code_verifier: expect.any(String) })); + await expect(client.readSigninResponseState(`rootline://auth/callback?code=x&state=${state}`, true)).rejects.toThrow(/state/i); + expect(browserWrite).not.toHaveBeenCalled(); + + const badNonceRequest = await client.createSigninRequest({ nonce: "expected-nonce" }); + const badNonceState = new URL(badNonceRequest.url).searchParams.get("state")!; + const jwt = [ + Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify({ sub: "alice", nonce: "wrong-nonce" })).toString("base64url"), + "signature", + ].join("."); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + access_token: "access", token_type: "Bearer", expires_in: 300, id_token: jwt, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await expect(client.processSigninResponse(`rootline://auth/callback?code=x&state=${badNonceState}`)) + .rejects.toThrow(/nonce.*does not match/i); + vi.unstubAllGlobals(); + browserWrite.mockRestore(); + }); + + test("projects identity claims without exposing access, ID, or refresh tokens", () => { + const visible = projectUser({ + profile: { sub: "subject", name: "Alice", email: "alice@example.com", permissions: ["rootline:profiles:sync"] }, + access_token: "secret-access", + id_token: "secret-id", + refresh_token: "secret-refresh", + expired: false, + }); + expect(visible).toEqual({ sub: "subject", name: "Alice", email: "alice@example.com", permissions: ["rootline:profiles:sync"] }); + expect(JSON.stringify(visible)).not.toContain("secret-"); + }); + + test("replays outbox at start, sign-in, manual sync, and a debounced profile edit while offline failures stay non-blocking", async () => { + vi.useFakeTimers(); + const sync = vi.fn() + .mockRejectedValueOnce(new TypeError("offline")) + .mockResolvedValue(undefined); + const auth = { sync } as unknown as AuthController; + const coordinator = new ProfileSyncCoordinator(auth, 750); + await expect(coordinator.start()).resolves.toBeUndefined(); + await coordinator.signedIn(); + await coordinator.manual(); + coordinator.profileEdited(); + coordinator.profileEdited(); + await vi.advanceTimersByTimeAsync(750); + expect(sync).toHaveBeenCalledTimes(4); + vi.useRealTimers(); + }); + + test("initializes once, serializes callbacks, and preserves a valid session after duplicate delivery", async () => { + const storedUser = { + profile: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + let callbacksInFlight = 0; + let maxCallbacksInFlight = 0; + const manager = { + getUser: vi.fn(async () => storedUser), + signinRedirectCallback: vi.fn(async () => { + callbacksInFlight += 1; + maxCallbacksInFlight = Math.max(maxCallbacksInFlight, callbacksInFlight); + await Promise.resolve(); + callbacksInFlight -= 1; + if (manager.signinRedirectCallback.mock.calls.length > 1) throw new Error("state already consumed"); + return storedUser; + }), + signinRedirect: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockResolvedValue({}); + const auth = new DesktopAuthController(config, manager as never); + await Promise.all([auth.initialize(), auth.initialize()]); + expect(manager.getUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(1); + expect(tauriMocks.listen).toHaveBeenCalledTimes(1); + + await Promise.all([ + auth.handleCallback("rootline://auth/callback?code=first&state=one"), + auth.handleCallback("rootline://auth/callback?code=duplicate&state=one"), + ]); + expect(maxCallbacksInFlight).toBe(1); + expect(auth.snapshot().user).toEqual(expect.objectContaining({ sub: "alice" })); + expect(auth.snapshot().error).toMatch(/already consumed/); + }); + + test("surfaces vault/startup and browser launch failures without leaving the offline UI loading", async () => { + const manager = { + getUser: vi.fn(async () => { throw new Error("OS credential unavailable"); }), + signinRedirect: vi.fn(async () => { throw new Error("system browser unavailable"); }), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await expect(auth.initialize()).rejects.toThrow(/credential unavailable/); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, user: null, error: "OS credential unavailable" })); + await expect(auth.signIn()).rejects.toThrow(/browser unavailable/); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, user: null, error: "system browser unavailable" })); + }); + + test("cleans a partial listener registration before retrying initialization", async () => { + const firstDeepLinkUnlisten = vi.fn(); + const secondDeepLinkUnlisten = vi.fn(); + const singleInstanceUnlisten = vi.fn(); + tauriMocks.onOpenUrl + .mockResolvedValueOnce(firstDeepLinkUnlisten) + .mockResolvedValueOnce(secondDeepLinkUnlisten); + tauriMocks.listen + .mockRejectedValueOnce(new Error("listener unavailable")) + .mockResolvedValueOnce(singleInstanceUnlisten); + const manager = { + getUser: vi.fn(async () => null), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await expect(auth.initialize()).rejects.toThrow(/listener unavailable/); + expect(firstDeepLinkUnlisten).toHaveBeenCalledTimes(1); + await auth.initialize(); + expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(2); + expect(tauriMocks.listen).toHaveBeenCalledTimes(2); + auth.dispose(); + expect(secondDeepLinkUnlisten).toHaveBeenCalledTimes(1); + expect(singleInstanceUnlisten).toHaveBeenCalledTimes(1); + }); + + test("commits account consent before scheduling best-effort hosted synchronization", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + let releaseSync!: () => void; + const pendingSync = new Promise((resolve) => { releaseSync = resolve; }); + tauriMocks.invoke.mockImplementation(async (command: string) => { + if (command === "sync_hosted_profiles") await pendingSync; + return {}; + }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.resolveAccountClaim(true); + expect(tauriMocks.invoke).toHaveBeenCalledWith("claim_hosted_account", { subject: "alice", uploadExisting: true }); + expect(auth.snapshot().accountClaimRequired).toBeFalsy(); + expect(tauriMocks.invoke).toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + releaseSync(); + }); +}); diff --git a/apps/desktop/src/test/stronghold-storage.test.ts b/apps/desktop/src/test/stronghold-storage.test.ts new file mode 100644 index 0000000..2cafbdd --- /dev/null +++ b/apps/desktop/src/test/stronghold-storage.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + load: vi.fn(), + save: vi.fn(), + values: new Map(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/path", () => ({ + appDataDir: vi.fn(async () => "/protected/app-data"), + join: vi.fn(async (...parts: string[]) => parts.join("/")), +})); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ onOpenUrl: vi.fn() })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("@tauri-apps/plugin-stronghold", () => ({ + Stronghold: { load: mocks.load }, +})); + +import { StrongholdAsyncStorage } from "../auth"; + +beforeEach(() => { + mocks.values.clear(); + mocks.invoke.mockReset().mockResolvedValue("a".repeat(64)); + mocks.save.mockReset().mockResolvedValue(undefined); + const store = { + get: vi.fn(async (key: string) => mocks.values.get(key) ?? null), + insert: vi.fn(async (key: string, value: number[]) => { mocks.values.set(key, Uint8Array.from(value)); }), + remove: vi.fn(async (key: string) => { mocks.values.delete(key); return null; }), + }; + const client = { getStore: () => store }; + mocks.load.mockReset().mockResolvedValue({ + loadClient: vi.fn(async () => { throw new Error("first install"); }), + createClient: vi.fn(async () => client), + save: mocks.save, + }); +}); + +test("persists OIDC state and tokens only through the OS-keyed Stronghold store", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + const storage = new StrongholdAsyncStorage(); + await storage.setItem("oidc.user", JSON.stringify({ access_token: "secret-token" })); + expect(mocks.invoke).toHaveBeenCalledWith("auth_vault_password"); + expect(mocks.load).toHaveBeenCalledWith("/protected/app-data/rootline-auth.stronghold", "a".repeat(64)); + expect(await storage.getItem("oidc.user")).toContain("secret-token"); + expect(browserWrite).not.toHaveBeenCalled(); + expect(mocks.save).toHaveBeenCalled(); + browserWrite.mockRestore(); +}); + +test("fails closed when the native OS credential boundary cannot return the existing vault key", async () => { + const browserWrite = vi.spyOn(Storage.prototype, "setItem"); + mocks.invoke.mockRejectedValueOnce(new Error("Stronghold vault exists but its OS-protected key is missing.")); + const storage = new StrongholdAsyncStorage(); + await expect(storage.setItem("oidc.user", "must-not-persist")).rejects.toThrow(/OS-protected key is missing/); + expect(mocks.load).not.toHaveBeenCalled(); + expect(browserWrite).not.toHaveBeenCalled(); + browserWrite.mockRestore(); +}); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index e793ff2..eb7a6f5 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -7,7 +7,7 @@ "moduleResolution": "Bundler", "rootDir": "src", "outDir": "dist", - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src"] } diff --git a/docs/README.md b/docs/README.md index a5d5157..519c205 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,3 +2,4 @@ - [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - [npm `folder-structure-sync@1.1.0` recovery](baseline/npm-1.1.0-recovery.md) +- [Hosted profile sync operations](hosted-profile-sync.md) - Authentik registration, API configuration, privacy contract, and fail-closed deployment gates. diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md new file mode 100644 index 0000000..1bdc1e3 --- /dev/null +++ b/docs/hosted-profile-sync.md @@ -0,0 +1,99 @@ +# Hosted profile sync operations + +Rootline's hosted sync is optional. An unconfigured desktop remains fully local; a partially configured desktop fails closed instead of attempting authentication. The API also refuses to start until its database and token-verification inputs are complete. + +Only saved profile documents are uploaded. A profile contains its name, absolute source and target paths, exclusions, timestamps, and additive sync mode. Directory trees, files, file contents, and run history never enter the hosted outbox. + +## Authentik registration + +Production registration is an external deployment gate. Create an Authentik OAuth2/OIDC provider and application with these exact properties: + +| Setting | Required value | +|---------|----------------| +| **Client type** | Public | +| **Grant** | Authorization Code with PKCE | +| **Redirect URI** | `rootline://auth/callback` | +| **Scopes** | `openid profile email permissions offline_access` | +| **Audience** | The value deployed as `JWT_AUDIENCE` | +| **Permission claim** | `rootline:profiles:sync` in the `permissions` array | +| **Signing algorithm** | RS256 | + +Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. + +Set all three desktop build variables together: + +```dotenv +VITE_AUTHENTIK_ISSUER=https://auth.example.com/application/o/rootline/ +VITE_AUTHENTIK_CLIENT_ID=rootline-desktop +VITE_ROOTLINE_SYNC_API=https://rootline-api.example.com +``` + +When all are absent, account controls are disabled and offline use continues. If only some are present, or either endpoint is not HTTPS, startup fails with an actionable configuration error. + +Profiles saved before the first sign-in remain unclaimed. Because they contain absolute paths, Rootline requires an explicit **Upload existing profiles** or **Keep local only** decision before binding their outbox to an OIDC subject. Signing out always removes that subject's cursor and queued mutations; choosing to keep local profiles does not make them eligible for a later account automatically. A different account therefore cannot inherit the previous account's paths or cursor. + +Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, and session generation; the response must still match all four values inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance advance the generation, so delayed or out-of-order responses are discarded. Repeating an already committed claim for the same subject is idempotent. + +## API deployment + +The API requires: + +```dotenv +DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require +JWT_ISSUER=https://auth.example.com/application/o/rootline/ +JWT_AUDIENCE=rootline-desktop +JWT_JWKS_PATH=/run/secrets/rootline-authentik-jwks.json +RATE_LIMIT_PER_MINUTE=60 +PORT=3000 +``` + +`JWT_JWKS_PATH` must be a deployment-mounted static JWKS containing the current Authentik RS256 public signing keys. Missing or empty files stop startup. Key rotation is an explicit rollout: mount a JWKS containing both accepted public keys, restart the API, rotate Authentik, then remove the retired key after all old access tokens expire. Never fetch keys dynamically from an untrusted token header. + +Provision encrypted PostgreSQL separately, then apply the checked-in migration before starting the API: + +```bash +pnpm --filter @rootline/api prisma:generate +pnpm --filter @rootline/api prisma:migrate:deploy +pnpm --filter @rootline/api start +``` + +The stable deployment must provide TLS termination for the API, TLS validation for PostgreSQL, encrypted backups, and a protected JWKS mount. Those production credentials and infrastructure are intentionally not committed to this repository. + +## Service contract and privacy limits + +| Endpoint | Contract | +|----------|----------| +| **`GET /healthz`** | Public liveness response; no account data | +| **`POST /v1/sync`** | Authenticated profile mutations and cursor delta | +| **`DELETE /v1/account-data`** | Deletes hosted data and rotates the user's epoch | + +Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names are limited to 120 characters, paths to 4096 characters, and exclusions to 100 entries of 512 characters each. + +The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. + +Server commit arrival order is last-write-wins. Every accepted mutation advances a per-user revision, deletes become tombstones, and mutation receipts remain idempotent for 90 days. Account deletion clears profiles, tombstones, changes, and receipts, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. + +Mutation IDs are bound to a canonical content hash; reuse with different content returns 409 instead of silently dropping a change. Delta pages contain at most 100 records and approximately 1 MiB of record JSON. `hasMore` and the returned cursor let the desktop drain long-offline deltas while enforcing a 2 MiB streaming response cap. + +Production logs must remain metadata-only: never log bearer tokens, request bodies, profile fields, or absolute paths. + +For a reproducible local integration run, Docker can provision a disposable PostgreSQL 16 instance, apply every real migration, execute the API e2e suite, and remove the instance automatically: + +```bash +pnpm --filter @rootline/api test:e2e:postgres +``` + +## Operator validation + +- [ ] Authentik registration is a public client with the exact redirect URI and scopes. +- [ ] The deployed static JWKS and Authentik signing keys agree. +- [ ] PostgreSQL connections and backups are encrypted. +- [ ] Database migrations completed before the API rollout. +- [ ] API ingress enforces HTTPS and the 256 KiB request limit is not raised upstream. +- [ ] Logs contain no tokens, request bodies, or absolute paths. +- [ ] Account deletion is tested with a second stale device and returns reset-required. + +## Related + +- [Rootline documentation](README.md) - Documentation navigation. +- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - Product constraints and acceptance criteria. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5b54b51..2db151b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -71,6 +71,7 @@ export interface CloudMutationReceipt { export interface CloudSyncResponse { epoch: string; cursor: string; + hasMore: boolean; records: readonly ProfileRecord[]; receipts: readonly CloudMutationReceipt[]; } @@ -98,6 +99,7 @@ export const ROOTLINE_ERROR_CODES = { AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", PROFILE_CONFLICT: "PROFILE_CONFLICT", SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", + SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index faddd09..7eb75fb 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -19,6 +19,7 @@ describe("Rootline contracts", () => { AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", PROFILE_CONFLICT: "PROFILE_CONFLICT", SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", + SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts index a002bbb..21c79c1 100644 --- a/packages/contracts/test/contracts.types.test.ts +++ b/packages/contracts/test/contracts.types.test.ts @@ -44,6 +44,7 @@ test("public domain and cloud contracts retain their transport shapes", () => { | "AUTH_CALLBACK_INVALID" | "PROFILE_CONFLICT" | "SYNC_EPOCH_RESET_REQUIRED" + | "SYNC_ACCOUNT_CLAIM_REQUIRED" | "RATE_LIMITED" | "VALIDATION_FAILED" | "INTERNAL" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4323d7e..e4b244c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,15 +16,98 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(jsdom@26.1.0) - - apps/api: {} + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) + + apps/api: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.2 + version: 4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + '@nestjs/swagger': + specifier: ^11.2.0 + version: 11.4.6(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^6.16.2 + version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.4 + express: + specifier: ^5.1.0 + version: 5.2.1 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + devDependencies: + '@nestjs/testing': + specifier: ^11.1.6 + version: 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(@nestjs/platform-express@11.2.1) + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + jose: + specifier: ^6.1.0 + version: 6.2.8 + prisma: + specifier: ^6.16.2 + version: 6.19.3(typescript@5.9.3) + supertest: + specifier: ^7.1.4 + version: 7.2.2 + tsx: + specifier: ^4.20.5 + version: 4.23.12 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) apps/desktop: dependencies: '@tauri-apps/api': specifier: ^2.8.0 version: 2.11.1 + '@tauri-apps/plugin-deep-link': + specifier: ^2.4.3 + version: 2.4.9 + '@tauri-apps/plugin-opener': + specifier: ^2.5.0 + version: 2.5.4 + '@tauri-apps/plugin-stronghold': + specifier: ^2.3.0 + version: 2.3.1 + oidc-client-ts: + specifier: ^3.3.0 + version: 3.5.0 react: specifier: ^19.1.1 version: 19.2.8 @@ -52,7 +135,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.2.0(vite@7.3.6(@types/node@24.13.3)) + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)) axe-core: specifier: ^4.10.3 version: 4.13.0 @@ -61,10 +144,10 @@ importers: version: 26.1.0 vite: specifier: ^7.1.2 - version: 7.3.6(@types/node@24.13.3) + version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(jsdom@26.1.0) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) packages/cli: dependencies: @@ -178,6 +261,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -378,6 +464,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -385,6 +478,135 @@ packages: os: [linux] libc: [glibc] + '@nestjs/common@11.2.1': + resolution: {integrity: sha512-SEgtP+M9DqNhQkgJIlJ3oTp3gemo/8owySovzMGmJj2kcfIH1G6QP45AAb8dE4a3IpVicpUvdDAy7Syk7ebjBw==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.2.1': + resolution: {integrity: sha512-M5PWFU8NdRTgX9Po49d7TQKg7f5t8GAVUa/Esy4tmaMWcKugTzg3ZzpJfD3LEPMuRHjfs6+8pQYgtuP2uz3rDw==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@11.2.1': + resolution: {integrity: sha512-lbaVW94s1u8AJfgmBtdMPi16MEuFBiLrnflUIA9tZ9e5eoUsGTN7XXXRjU5kTTLgjnTCM53qgBXXGmTqmyfoQA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/swagger@11.4.6': + resolution: {integrity: sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@11.2.1': + resolution: {integrity: sha512-3mdABjqFafW+ix6fGJMPHvnj/9Or6kAPiszN8zt9bHGPuHYdkkp+9zsBDDUVuP59BcbzBqNBa5fXHBaeMzVvog==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@prisma/client@6.19.3': + resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.3': + resolution: {integrity: sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==} + + '@prisma/debug@6.19.3': + resolution: {integrity: sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.3': + resolution: {integrity: sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==} + + '@prisma/fetch-engine@6.19.3': + resolution: {integrity: sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==} + + '@prisma/get-platform@6.19.3': + resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -526,6 +748,12 @@ packages: cpu: [x64] os: [win32] + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} @@ -605,6 +833,15 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-deep-link@2.4.9': + resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-stronghold@2.3.1': + resolution: {integrity: sha512-zFbD1Apk/VFdWaoGaoKcouRrZnzLFiNY9b1KDeBaN47sMaMHRYIa+ZDhvbzMOyH314+OHCQBXfe8I/ph59Lp9g==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -637,6 +874,13 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -652,18 +896,60 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.3': + resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.4': resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: @@ -672,6 +958,21 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -707,6 +1008,10 @@ packages: '@vitest/utils@3.2.7': resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -719,6 +1024,12 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -726,10 +1037,16 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axe-core@4.13.0: resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} @@ -739,15 +1056,49 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} @@ -759,9 +1110,70 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -792,26 +1204,96 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.21.0: + resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + electron-to-chromium@1.5.406: resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -821,13 +1303,34 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -837,19 +1340,78 @@ packages: picomatch: optional: true + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -862,19 +1424,51 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@5.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -894,6 +1488,51 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + libphonenumber-js@1.13.11: + resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -910,6 +1549,47 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -917,11 +1597,22 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-releases@2.0.53: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} @@ -929,9 +1620,54 @@ packages: nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + nypm@0.6.9: + resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} + engines: {node: '>=18'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -939,6 +1675,12 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -946,6 +1688,9 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -954,10 +1699,42 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prisma@6.19.3: + resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -974,18 +1751,39 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} - redent@3.0.0: + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1000,6 +1798,38 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1010,9 +1840,20 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1020,6 +1861,21 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -1029,6 +1885,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1052,6 +1912,14 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -1060,20 +1928,66 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.3.1: resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1173,6 +2087,9 @@ packages: engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -1321,6 +2238,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@borewit/text-codec@0.2.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -1438,9 +2357,138 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.4(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/passport@11.0.5(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/swagger@11.4.6(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 5.2.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.8 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/testing@11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1)(@nestjs/platform-express@11.2.1)': + dependencies: + '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.1) + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.3(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.3': + dependencies: + c12: 3.1.0 + deepmerge-ts: 7.1.5 + effect: 3.21.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.3': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.3 + '@prisma/get-platform': 6.19.3 + + '@prisma/fetch-engine@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.3 + + '@prisma/get-platform@6.19.3': + dependencies: + '@prisma/debug': 6.19.3 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.62.4': @@ -1518,6 +2566,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@scarf/scarf@1.4.0': {} + + '@standard-schema/spec@1.1.0': {} + '@tauri-apps/api@2.11.1': {} '@tauri-apps/cli-darwin-arm64@2.11.4': @@ -1567,6 +2619,18 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-deep-link@2.4.9': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-stronghold@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -1602,6 +2666,15 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -1625,19 +2698,72 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.13.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.13.3 + + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.3': + dependencies: + '@types/node': 24.13.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.3 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.13.3 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -1646,7 +2772,30 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3))': + '@types/send@1.2.1': + dependencies: + '@types/node': 24.13.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.13.3 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.13.3 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -1654,7 +2803,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@24.13.3) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) transitivePeerDependencies: - supports-color @@ -1666,13 +2815,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.13.3) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) '@vitest/pretty-format@3.2.7': dependencies: @@ -1700,24 +2849,51 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + agent-base@7.1.4: {} ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} + append-field@1.0.0: {} + + argparse@2.0.1: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 aria-query@5.3.2: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} + axe-core@4.13.0: {} baseline-browser-mapping@2.11.14: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.14 @@ -1726,8 +2902,43 @@ snapshots: node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.1 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.1 + rc9: 2.1.2 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001809: {} chai@5.3.3: @@ -1740,8 +2951,60 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.11 + validator: 13.15.35 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + css.escape@1.5.1: {} cssstyle@4.6.0: @@ -1764,18 +3027,77 @@ snapshots: deep-eql@5.0.2: {} + deepmerge-ts@7.1.5: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + effect@3.21.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.406: {} + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + entities@6.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1807,25 +3129,157 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + expect-type@1.4.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-safe-stringify@2.1.1: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.9 + pathe: 2.0.3 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -1844,14 +3298,36 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + + iterare@1.2.1: {} + + jiti@2.7.0: {} + + jose@6.2.8: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} + js-yaml@5.2.1: + dependencies: + argparse: 2.0.1 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -1883,6 +3359,52 @@ snapshots: json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + jwt-decode@4.0.0: {} + + libphonenumber-js@1.13.11: {} + + load-esm@1.0.3: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -1897,28 +3419,114 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + min-indent@1.0.1: {} ms@2.1.3: {} + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + nanoid@3.3.18: {} + negotiator@1.0.0: {} + + node-fetch-native@1.6.7: {} + node-releases@2.0.53: {} nwsapi@2.2.24: {} + nypm@0.6.9: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + ohash@2.0.12: {} + + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + parse5@7.3.0: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} + pause@0.0.1: {} + + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -1931,8 +3539,43 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prisma@6.19.3(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.3 + '@prisma/engines': 6.19.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + pure-rand@6.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -1944,11 +3587,21 @@ snapshots: react@19.2.8: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -1981,8 +3634,24 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -1993,14 +3662,79 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2009,12 +3743,44 @@ snapshots: dependencies: js-tokens: 9.0.1 + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-tree@3.2.4: {} tinybench@2.9.0: {} tinyexec@0.3.2: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -2032,6 +3798,14 @@ snapshots: dependencies: tldts-core: 6.1.86 + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -2040,23 +3814,60 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + typescript@5.9.3: {} + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + undici-types@7.18.2: {} + unpipe@1.0.0: {} + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 - vite-node@3.2.4(@types/node@24.13.3): + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + validator@13.15.35: {} + + vary@1.1.2: {} + + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@24.13.3) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) transitivePeerDependencies: - '@types/node' - jiti @@ -2071,7 +3882,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@24.13.3): + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -2082,12 +3893,14 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 - vitest@3.2.7(@types/node@24.13.3)(jsdom@26.1.0): + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -2105,8 +3918,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@24.13.3) - vite-node: 3.2.4(@types/node@24.13.3) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -2147,6 +3960,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrappy@1.0.2: {} + ws@8.21.3: {} xml-name-validator@5.0.0: {} From ebd12b09f6f1d2bdf120f76e5ad037b994f28b00 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 07:15:55 +0700 Subject: [PATCH 13/26] fix: harden hosted sync lifecycle --- apps/api/package.json | 2 +- apps/api/test/sync.e2e.spec.ts | 40 ++ .../0004_consented_epoch_adoption.sql | 9 + apps/desktop/src-tauri/src/lib.rs | 259 +++++++---- .../src-tauri/tests/hosted_sync_postgres.rs | 83 ++++ apps/desktop/src-tauri/tests/native.rs | 434 +++++++++++++++++- apps/desktop/src/auth.ts | 25 +- apps/desktop/src/components/AuthControls.tsx | 2 +- apps/desktop/src/test/AuthControls.test.tsx | 21 + apps/desktop/src/test/auth.test.ts | 90 +++- .../src/test/stronghold-storage.test.ts | 2 +- docs/hosted-profile-sync.md | 14 +- packages/contracts/src/index.ts | 1 + packages/contracts/test/contracts.test.ts | 1 + .../contracts/test/contracts.types.test.ts | 1 + 15 files changed, 854 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql create mode 100644 apps/desktop/src-tauri/tests/hosted_sync_postgres.rs diff --git a/apps/api/package.json b/apps/api/package.json index 47b7b05..5686ce6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/main.js", - "test": "vitest run", + "test": "sh scripts/test-e2e-postgres.sh", "test:e2e": "vitest run test/sync.e2e.spec.ts", "test:e2e:postgres": "sh scripts/test-e2e-postgres.sh", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index 272564a..956416f 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -1,7 +1,10 @@ import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import request from "supertest"; import { Logger } from "@nestjs/common"; import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; @@ -12,11 +15,13 @@ const ISSUER = "https://auth.baole.space/application/o/rootline/"; const AUDIENCE = "rootline-desktop"; const EPOCH = "00000000-0000-4000-8000-000000000001"; const PROFILE_ID = "profile-existing-task3"; +const execFileAsync = promisify(execFile); describe("Rootline hosted sync (real PostgreSQL)", () => { let fixture: TestApplication; let directory: string; let privateKey: CryptoKey; + let apiUrl: string; beforeAll(async () => { if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL must point to a real PostgreSQL test database"); @@ -34,6 +39,10 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { jwksPath, rateLimit: 60, }); + await fixture.app.listen(0, "127.0.0.1"); + const address = fixture.server.address(); + if (!address || typeof address === "string") throw new Error("Test API did not bind a TCP port"); + apiUrl = `http://127.0.0.1:${address.port}`; await fixture.resetDatabase(); }); @@ -133,6 +142,37 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { expect(empty.body.records).toEqual([]); }); + test("replays a real desktop SQLite device-two outbox through Nest and PostgreSQL without cross-account path leakage", async () => { + const alice = await token("seam-alice"); + const bob = await token("seam-bob"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ + deviceId: "device-one", + epoch: EPOCH, + mutations: [mutation("device-one-profile", "Device one", "00000000-0000-4000-8008-000000000001")], + }).expect(200); + + const manifest = fileURLToPath(new URL("../../desktop/src-tauri/Cargo.toml", import.meta.url)); + await execFileAsync("cargo", [ + "test", "--manifest-path", manifest, "--test", "hosted_sync_postgres", "--", "--ignored", "--nocapture", + ], { + env: { + ...process.env, + ROOTLINE_E2E_API_URL: apiUrl, + ROOTLINE_E2E_ALICE_TOKEN: alice, + ROOTLINE_E2E_BOB_TOKEN: bob, + }, + maxBuffer: 2 * 1024 * 1024, + timeout: 180_000, + }); + + const aliceRecords = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${alice}`) + .send({ deviceId: "verification-device", epoch: EPOCH, mutations: [] }).expect(200); + expect(aliceRecords.body.records).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "profile", profile: expect.objectContaining({ id: "device-two-offline-profile" }) }), + ])); + }, 180_000); + test("rotates epoch on account deletion and rejects stale-device resurrection", async () => { const auth = await token("reset-user"); await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) diff --git a/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql b/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql new file mode 100644 index 0000000..fcad241 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0004_consented_epoch_adoption.sql @@ -0,0 +1,9 @@ +ALTER TABLE sync_state ADD COLUMN preserve_outbox_on_epoch_adopt INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mutation_outbox ADD COLUMN preserve_on_epoch_adopt INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mutation_outbox ADD COLUMN profile_id TEXT NOT NULL DEFAULT ''; +UPDATE mutation_outbox +SET profile_id = CASE + WHEN kind = 'upsert' THEN json_extract(payload, '$.id') + WHEN kind = 'delete' THEN json_extract(payload, '$.profileId') + ELSE '' +END; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 920b52f..ddc5a4f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ use rand::RngCore; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::json; -use tauri::{AppHandle, Emitter, Manager, State}; +use tauri::{AppHandle, Manager, State}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use uuid::Uuid; @@ -33,6 +33,7 @@ pub enum NativeErrorCode { AuthCallbackInvalid, SyncEpochResetRequired, SyncAccountClaimRequired, + SyncStateChanged, Internal, } @@ -121,29 +122,6 @@ fn auth_vault_password(app: AppHandle) -> Result { } } -pub fn strict_auth_callback_arg(args: &[String]) -> Option { - args.iter().find_map(|argument| { - let parsed = url::Url::parse(argument).ok()?; - if parsed.scheme() != "rootline" - || parsed.host_str() != Some("auth") - || parsed.path() != "/callback" - || parsed.fragment().is_some() - { - return None; - } - let pairs: Vec<_> = parsed.query_pairs().collect(); - let codes: Vec<_> = pairs - .iter() - .filter(|(key, value)| key == "code" && !value.is_empty()) - .collect(); - let states: Vec<_> = pairs - .iter() - .filter(|(key, value)| key == "state" && !value.is_empty()) - .collect(); - (codes.len() == 1 && states.len() == 1).then(|| argument.clone()) - }) -} - #[derive(Clone, Default)] pub struct CancellationToken(Arc); @@ -716,6 +694,10 @@ const MIGRATIONS: &[(i64, &str)] = &[ 3, include_str!("../migrations/0003_sync_session_generation.sql"), ), + ( + 4, + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + ), ]; const HOSTED_SYNC_MUTATION_LIMIT: usize = 100; const HOSTED_SYNC_BODY_LIMIT: usize = 256 * 1024; @@ -787,8 +769,22 @@ impl Database { profile.created_at, profile.updated_at], )?; transaction.execute( - "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, 'upsert', ?2, ?3)", - params![Uuid::new_v4().to_string(), payload, profile.updated_at], + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES ( + ?1, 'upsert', ?2, ?3, ?4, + EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?4 AND preserve_on_epoch_adopt=1) + )", + params![ + Uuid::new_v4().to_string(), + payload, + profile.updated_at, + profile.id + ], + )?; + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], )?; transaction.commit()?; Ok(()) @@ -820,8 +816,22 @@ impl Database { let transaction = connection.transaction()?; transaction.execute("DELETE FROM profiles WHERE id = ?1", [id])?; transaction.execute( - "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, 'delete', ?2, ?3)", - params![Uuid::new_v4().to_string(), json!({ "profileId": id }).to_string(), timestamp()], + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES ( + ?1, 'delete', ?2, ?3, ?4, + EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?4 AND preserve_on_epoch_adopt=1) + )", + params![ + Uuid::new_v4().to_string(), + json!({ "profileId": id }).to_string(), + timestamp(), + id + ], + )?; + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], )?; transaction.commit()?; Ok(()) @@ -834,9 +844,21 @@ impl Database { payload: &str, occurred_at: &str, ) -> Result<(), NativeError> { + let parsed: serde_json::Value = serde_json::from_str(payload).map_err(internal_error)?; + let profile_id = parsed + .get(if kind == "upsert" { "id" } else { "profileId" }) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A local outbox mutation is invalid.", + ) + })?; self.connection().execute( - "INSERT OR IGNORE INTO mutation_outbox(mutation_id, kind, payload, occurred_at) VALUES (?1, ?2, ?3, ?4)", - params![id, kind, payload, occurred_at], + "INSERT OR IGNORE INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, kind, payload, occurred_at, profile_id], )?; Ok(()) } @@ -905,9 +927,11 @@ impl Database { cursor: &str, ) -> Result<(), NativeError> { self.connection().execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation) VALUES (1, ?1, ?2, ?3, 1) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) + VALUES (1, ?1, ?2, ?3, 1, 0) ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, - cursor=excluded.cursor, session_generation=sync_state.session_generation + 1", + cursor=excluded.cursor, session_generation=sync_state.session_generation + 1, + preserve_outbox_on_epoch_adopt=0", params![subject, epoch, cursor], )?; Ok(()) @@ -931,10 +955,10 @@ impl Database { let transaction = connection.transaction()?; transaction.execute("DELETE FROM mutation_outbox", [])?; transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation) - VALUES (1, '', '', '', 1) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) + VALUES (1, '', '', '', 1, 0) ON CONFLICT(singleton) DO UPDATE SET subject='', epoch='', cursor='', - session_generation=sync_state.session_generation + 1", + session_generation=sync_state.session_generation + 1, preserve_outbox_on_epoch_adopt=0", [], )?; if remove_local_profiles { @@ -979,13 +1003,17 @@ impl Database { } if !upload_existing { transaction.execute("DELETE FROM mutation_outbox", [])?; + } else { + transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; } let epoch = Uuid::new_v4().to_string(); transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation) VALUES (1, ?1, ?2, '', 1) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) + VALUES (1, ?1, ?2, '', 1, ?3) ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, - cursor='', session_generation=sync_state.session_generation + 1", - params![subject, epoch], + cursor='', session_generation=sync_state.session_generation + 1, + preserve_outbox_on_epoch_adopt=excluded.preserve_outbox_on_epoch_adopt", + params![subject, epoch, i64::from(upload_existing)], )?; transaction.commit()?; Ok(()) @@ -999,20 +1027,56 @@ impl Database { ) -> Result<(), NativeError> { let mut connection = self.connection(); let transaction = connection.transaction()?; - transaction.execute("DELETE FROM mutation_outbox", [])?; + let preserve_consented_outbox = !remove_local_profiles + && transaction + .query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + ) FROM sync_state + WHERE singleton=1 AND subject=?1 AND preserve_outbox_on_epoch_adopt=1", + [subject], + |row| row.get::<_, i64>(0), + ) + .optional()? + == Some(1); + if preserve_consented_outbox { + transaction.execute( + "DELETE FROM mutation_outbox WHERE preserve_on_epoch_adopt=0", + [], + )?; + } else { + transaction.execute("DELETE FROM mutation_outbox", [])?; + } if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; } transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation) VALUES (1, ?1, ?2, '', 1) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) + VALUES (1, ?1, ?2, '', 1, 0) ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, - cursor='', session_generation=sync_state.session_generation + 1", - params![subject, epoch], + cursor='', session_generation=sync_state.session_generation + 1, + preserve_outbox_on_epoch_adopt=CASE WHEN ?3 THEN 1 ELSE 0 END", + params![subject, epoch, preserve_consented_outbox], )?; transaction.commit()?; Ok(()) } + pub fn preserves_consented_outbox(&self, subject: &str) -> Result { + Ok(self + .connection() + .query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + ) FROM sync_state + WHERE singleton=1 AND subject=?1 AND preserve_outbox_on_epoch_adopt=1", + [subject], + |row| row.get::<_, i64>(0), + ) + .optional()? + == Some(1)) + } + pub fn accept_account_epoch_if_current( &self, expected_subject: &str, @@ -1040,7 +1104,7 @@ impl Database { )) { return Err(NativeError::new( - NativeErrorCode::Internal, + NativeErrorCode::SyncStateChanged, "Hosted sync state changed; the stale account response was discarded.", )); } @@ -1049,7 +1113,8 @@ impl Database { transaction.execute("DELETE FROM profiles", [])?; } transaction.execute( - "UPDATE sync_state SET epoch=?1, cursor='', session_generation=session_generation + 1 + "UPDATE sync_state SET epoch=?1, cursor='', session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=0 WHERE singleton=1 AND subject=?2 AND epoch=?3 AND cursor=?4 AND session_generation=?5", params![ next_epoch, @@ -1233,10 +1298,25 @@ impl Database { )) { return Err(NativeError::new( - NativeErrorCode::Internal, + NativeErrorCode::SyncStateChanged, "Hosted sync state changed; the stale response was discarded.", )); } + for receipt in receipts { + let mutation_id = receipt + .get("mutationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "A hosted mutation receipt is invalid.", + ) + })?; + transaction.execute( + "DELETE FROM mutation_outbox WHERE mutation_id = ?1", + [mutation_id], + )?; + } for record in records { match record.get("kind").and_then(serde_json::Value::as_str) { Some("profile") => { @@ -1249,6 +1329,14 @@ impl Database { })?, ) .map_err(internal_error)?; + let has_pending_local_mutation: i64 = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + [&profile.id], + |row| row.get(0), + )?; + if has_pending_local_mutation == 1 { + continue; + } transaction.execute( "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) @@ -1268,6 +1356,14 @@ impl Database { "A hosted tombstone is invalid.", ) })?; + let has_pending_local_mutation: i64 = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + [profile_id], + |row| row.get(0), + )?; + if has_pending_local_mutation == 1 { + continue; + } transaction.execute("DELETE FROM profiles WHERE id = ?1", [profile_id])?; } _ => { @@ -1278,29 +1374,20 @@ impl Database { } } } - for receipt in receipts { - let mutation_id = receipt - .get("mutationId") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - NativeError::new( - NativeErrorCode::Internal, - "A hosted mutation receipt is invalid.", - ) - })?; - transaction.execute( - "DELETE FROM mutation_outbox WHERE mutation_id = ?1", - [mutation_id], - )?; - } let updated = transaction.execute( - "UPDATE sync_state SET cursor = ?1 + "UPDATE sync_state SET cursor = ?1, + preserve_outbox_on_epoch_adopt=CASE + WHEN EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + ) THEN preserve_outbox_on_epoch_adopt + ELSE 0 + END WHERE singleton = 1 AND subject = ?2 AND epoch = ?3 AND cursor = ?4 AND session_generation = ?5", params![cursor, expected_subject, expected_epoch, expected_cursor, expected_generation], )?; if updated != 1 { return Err(NativeError::new( - NativeErrorCode::Internal, + NativeErrorCode::SyncStateChanged, "Hosted sync state changed; the stale response was discarded.", )); } @@ -1558,15 +1645,24 @@ async fn sync_hosted_profiles( && body.get("code").and_then(serde_json::Value::as_str) == Some("SYNC_EPOCH_RESET_REQUIRED") { + let preserves_consented_outbox = database.preserves_consented_outbox(&subject)?; return Err(NativeError { code: NativeErrorCode::SyncEpochResetRequired, - message: + message: if preserves_consented_outbox { + "This account already has hosted data. Review the explicitly consented local profiles before uploading." + } else { "Hosted profile data was reset. Review this device before uploading again." - .into(), + } + .into(), details: body .get("epoch") .cloned() - .map(|epoch| json!({ "epoch": epoch })), + .map(|epoch| { + json!({ + "epoch": epoch, + "preservesConsentedOutbox": preserves_consented_outbox + }) + }), }); } if !status.is_success() { @@ -1579,26 +1675,30 @@ async fn sync_hosted_profiles( "Hosted sync was rejected; local data remains safe.", )); } - acknowledged += body - .get("receipts") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); - records_applied += body - .get("records") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); let response_cursor = body .get("cursor") .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_owned(); - database.apply_hosted_sync_response( + match database.apply_hosted_sync_response( &subject, &expected_epoch, &expected_cursor, expected_generation, &body, - )?; + ) { + Ok(()) => {} + Err(error) if error.code == NativeErrorCode::SyncStateChanged => continue, + Err(error) => return Err(error), + } + acknowledged += body + .get("receipts") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + records_applied += body + .get("records") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); let has_more = body .get("hasMore") .and_then(serde_json::Value::as_bool) @@ -1760,12 +1860,9 @@ async fn delete_hosted_account_data( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - if let Some(url) = strict_auth_callback_arg(&args) { - let _ = app.emit("rootline-auth-deep-link", url); - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_focus(); - } + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); } })) .plugin(tauri_plugin_deep_link::init()) diff --git a/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs new file mode 100644 index 0000000..30d182a --- /dev/null +++ b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs @@ -0,0 +1,83 @@ +use rootline_desktop::{Database, Profile}; +use serde_json::Value; +use tempfile::tempdir; + +fn post_sync( + client: &reqwest::blocking::Client, + api_url: &str, + token: &str, + body: &Value, +) -> reqwest::blocking::Response { + client + .post(format!("{api_url}/v1/sync")) + .bearer_auth(token) + .json(body) + .send() + .unwrap() +} + +#[test] +#[ignore = "run by the real PostgreSQL API harness"] +fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_account() { + let api_url = std::env::var("ROOTLINE_E2E_API_URL").expect("ROOTLINE_E2E_API_URL"); + let alice_token = std::env::var("ROOTLINE_E2E_ALICE_TOKEN").expect("ROOTLINE_E2E_ALICE_TOKEN"); + let bob_token = std::env::var("ROOTLINE_E2E_BOB_TOKEN").expect("ROOTLINE_E2E_BOB_TOKEN"); + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("device-two.sqlite3")).unwrap(); + let local = Profile { + id: "device-two-offline-profile".into(), + name: "Device two offline".into(), + source_path: "/Users/device-two/private/source".into(), + target_path: "/Volumes/device-two/backup".into(), + exclusions: vec![".git".into()], + created_at: "2026-08-15T00:00:00.000Z".into(), + updated_at: "2026-08-15T00:00:00.000Z".into(), + }; + database.save_profile(&local).unwrap(); + database.claim_hosted_account("seam-alice", true).unwrap(); + let client = reqwest::blocking::Client::new(); + + let first = database.hosted_sync_request("seam-alice").unwrap(); + let conflict = post_sync(&client, &api_url, &alice_token, &first); + assert_eq!(conflict.status(), reqwest::StatusCode::CONFLICT); + let server_epoch = conflict.json::().unwrap()["epoch"] + .as_str() + .unwrap() + .to_owned(); + database + .accept_account_epoch("seam-alice", &server_epoch, false) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + + let reconnect = database.hosted_sync_request("seam-alice").unwrap(); + assert!(reconnect + .to_string() + .contains("/Users/device-two/private/source")); + let generation = database + .hosted_sync_generation("seam-alice", &server_epoch, "") + .unwrap(); + let response = post_sync(&client, &api_url, &alice_token, &reconnect); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.json::().unwrap(); + assert_eq!(body["receipts"].as_array().unwrap().len(), 1); + database + .apply_hosted_sync_response("seam-alice", &server_epoch, "", generation, &body) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + + database.disconnect_hosted_account(false).unwrap(); + let bob = database.hosted_sync_request("seam-bob").unwrap(); + assert_eq!(bob["mutations"], serde_json::json!([])); + assert!(!bob.to_string().contains("device-two/private")); + let bob_epoch = bob["epoch"].as_str().unwrap().to_owned(); + let bob_generation = database + .hosted_sync_generation("seam-bob", &bob_epoch, "") + .unwrap(); + let response = post_sync(&client, &api_url, &bob_token, &bob); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let bob_body = response.json::().unwrap(); + assert_eq!(bob_body["records"], serde_json::json!([])); + database + .apply_hosted_sync_response("seam-bob", &bob_epoch, "", bob_generation, &bob_body) + .unwrap(); +} diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index 416db85..73da4ab 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -2,8 +2,7 @@ use std::fs; use rootline_desktop::{ apply_plan, detect_case_sensitive, random_vault_password, resolve_existing_vault_password, - scan_plan, strict_auth_callback_arg, CancellationToken, Database, DirectoryStatus, - NativeErrorCode, Profile, ScanRequest, + scan_plan, CancellationToken, Database, DirectoryStatus, NativeErrorCode, Profile, ScanRequest, }; use rusqlite::Connection; use tempfile::tempdir; @@ -258,25 +257,6 @@ fn refuses_to_replace_a_missing_os_key_for_an_existing_stronghold_snapshot() { ); } -#[test] -fn filters_single_instance_arguments_to_the_exact_auth_callback() { - assert_eq!( - strict_auth_callback_arg(&[ - "rootline".into(), - "rootline://auth/callback?code=x&state=y".into() - ]), - Some("rootline://auth/callback?code=x&state=y".into()), - ); - for invalid in [ - "https://auth/callback?code=x&state=y", - "rootline://evil/callback?code=x&state=y", - "rootline://auth/callback/extra?code=x&state=y", - "rootline://auth/callback?state=y", - ] { - assert_eq!(strict_auth_callback_arg(&[invalid.into()]), None); - } -} - #[test] fn clearing_synced_local_data_is_explicit_and_keeps_run_history() { let directory = tempdir().unwrap(); @@ -457,6 +437,99 @@ fn hosted_outbox_batches_offline_replay_within_api_count_and_body_limits() { assert!(batches > 1); } +#[test] +fn first_batch_response_cannot_overwrite_a_same_profile_edit_queued_in_the_next_batch() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("batch-edit-fence.sqlite3")).unwrap(); + database.hosted_sync_request("alice").unwrap(); + let mut profile = Profile { + id: "batch-profile".into(), + name: String::new(), + source_path: "/batch/source".into(), + target_path: "/batch/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + for index in 0..=100 { + profile.name = format!("Local {index}"); + profile.updated_at = format!("2026-08-15T00:{:02}:00Z", index % 60); + database.save_profile(&profile).unwrap(); + } + let first = database.hosted_sync_request("alice").unwrap(); + assert_eq!(first["mutations"].as_array().unwrap().len(), 100); + let epoch = first["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + let server_profile = first["mutations"][99]["profile"].clone(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "batch-one", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 100, "profile": server_profile }], + "receipts": first["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap()[0].name, "Local 100"); + assert_eq!(database.pending_outbox().unwrap().len(), 1); +} + +#[test] +fn first_batch_response_cannot_resurrect_a_delete_queued_in_the_next_batch() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("batch-delete-fence.sqlite3")).unwrap(); + database.hosted_sync_request("alice").unwrap(); + let mut profile = Profile { + id: "batch-profile".into(), + name: String::new(), + source_path: "/batch/source".into(), + target_path: "/batch/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + for index in 0..100 { + profile.name = format!("Local {index}"); + profile.updated_at = format!("2026-08-15T00:{:02}:00Z", index % 60); + database.save_profile(&profile).unwrap(); + } + database.delete_profile(&profile.id).unwrap(); + let first = database.hosted_sync_request("alice").unwrap(); + assert_eq!(first["mutations"].as_array().unwrap().len(), 100); + let epoch = first["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + let server_profile = first["mutations"][99]["profile"].clone(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "batch-one", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 100, "profile": server_profile }], + "receipts": first["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + let pending = database.pending_outbox().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].kind, "delete"); +} + #[test] fn delayed_sync_responses_are_fenced_after_disconnect_switch_reset_and_out_of_order_apply() { let directory = tempdir().unwrap(); @@ -582,6 +655,325 @@ fn claiming_the_same_subject_twice_preserves_the_committed_epoch() { ); } +#[test] +fn local_save_invalidates_an_inflight_response_before_it_can_overwrite_the_new_profile() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("save-race.sqlite3")).unwrap(); + let mut profile = Profile { + id: "profile-race-save".into(), + name: "Before request".into(), + source_path: "/new/local/source".into(), + target_path: "/new/local/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let epoch = request["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + + profile.name = "Edited while response was delayed".into(); + profile.updated_at = "2026-08-15T01:00:00Z".into(); + database.save_profile(&profile).unwrap(); + let stale = serde_json::json!({ + "epoch": epoch, "cursor": "stale-save", "hasMore": false, + "records": [{ + "kind": "profile", "revision": 1, + "profile": { + "id": profile.id, "name": "Remote stale value", "sourcePath": "/remote/stale", + "targetPath": "/remote/stale", "exclusions": [], + "createdAt": profile.created_at, "updatedAt": "2026-08-14T00:00:00Z" + } + }], + "receipts": [{ "mutationId": request["mutations"][0]["mutationId"], "revision": 1 }] + }); + assert_eq!( + database + .apply_hosted_sync_response("alice", &epoch, "", generation, &stale) + .unwrap_err() + .code, + NativeErrorCode::SyncStateChanged, + ); + assert_eq!(database.list_profiles().unwrap(), [profile.clone()]); + let replay = database.hosted_sync_request("alice").unwrap(); + assert_eq!(replay["mutations"].as_array().unwrap().len(), 2); + assert!(replay + .to_string() + .contains("Edited while response was delayed")); +} + +#[test] +fn local_delete_invalidates_an_inflight_profile_before_it_can_resurrect_the_profile() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("delete-race.sqlite3")).unwrap(); + let profile = Profile { + id: "profile-race-delete".into(), + name: "Delete locally".into(), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let initial = database.hosted_sync_request("alice").unwrap(); + let epoch = initial["epoch"].as_str().unwrap().to_owned(); + let initial_generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + initial_generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "cursor-before-delete", "hasMore": false, + "records": [], + "receipts": [{ "mutationId": initial["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let generation = database + .hosted_sync_generation("alice", &epoch, "cursor-before-delete") + .unwrap(); + + database.delete_profile(&profile.id).unwrap(); + assert_eq!( + database + .apply_hosted_sync_response( + "alice", + &epoch, + "cursor-before-delete", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "stale-delete", "hasMore": false, + "records": [{ "kind": "profile", "revision": 2, "profile": profile }], + "receipts": [] + }) + ) + .unwrap_err() + .code, + NativeErrorCode::SyncStateChanged, + ); + assert!(database.list_profiles().unwrap().is_empty()); + let replay = database.hosted_sync_request("alice").unwrap(); + assert_eq!(replay["mutations"][0]["kind"], "delete"); + assert_eq!(request["mutations"], serde_json::json!([])); +} + +#[test] +fn adopting_an_existing_server_epoch_preserves_only_explicitly_consented_unclaimed_outbox() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("epoch-adoption.sqlite3")).unwrap(); + let profile = Profile { + id: "device-two-local".into(), + name: "Explicitly consented".into(), + source_path: "/device-two/private".into(), + target_path: "/device-two/backup".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000111", false) + .unwrap(); + let adopted = database.hosted_sync_request("alice").unwrap(); + assert_eq!(adopted["mutations"].as_array().unwrap().len(), 1); + assert!(adopted.to_string().contains("/device-two/private")); + + let epoch = adopted["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "cloud-owned", "hasMore": false, "records": [], + "receipts": [{ "mutationId": adopted["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + database + .save_profile(&Profile { + name: "Cloud-owned edit".into(), + ..profile + }) + .unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000112", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn epoch_adoption_keeps_only_unreceipted_consented_mutations_and_drops_new_cloud_edits() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("partial-consent.sqlite3")).unwrap(); + for id in ["consented-one", "consented-two"] { + database + .save_profile(&Profile { + id: id.into(), + name: id.into(), + source_path: format!("/{id}/source"), + target_path: format!("/{id}/target"), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }) + .unwrap(); + } + database.claim_hosted_account("alice", true).unwrap(); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000121", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + let epoch = request["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "partial", "hasMore": false, "records": [], + "receipts": [{ "mutationId": request["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database + .save_profile(&Profile { + id: "cloud-owned-new-edit".into(), + name: "Cloud-owned new edit".into(), + source_path: "/cloud/source".into(), + target_path: "/cloud/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:01:00Z".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + }) + .unwrap(); + assert_eq!(database.pending_outbox().unwrap().len(), 2); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000122", false) + .unwrap(); + let preserved = database.pending_outbox().unwrap(); + assert_eq!(preserved.len(), 1); + assert!(preserved[0].payload.contains("consented-two")); + assert!(!preserved[0].payload.contains("cloud-owned-new-edit")); +} + +#[test] +fn editing_a_consented_profile_before_epoch_adoption_preserves_the_newer_edit() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("consented-edit.sqlite3")).unwrap(); + let mut profile = Profile { + id: "consented-profile".into(), + name: "Old value".into(), + source_path: "/old/source".into(), + target_path: "/old/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + profile.name = "New value".into(); + profile.updated_at = "2026-08-15T00:01:00Z".into(); + database.save_profile(&profile).unwrap(); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000131", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 2); + assert_eq!(request["mutations"][1]["profile"]["name"], "New value"); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "edited", + "hasMore": false, + "records": [{ "kind": "profile", "revision": 2, "profile": profile }], + "receipts": request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap()[0].name, "New value"); + assert!(database.pending_outbox().unwrap().is_empty()); +} + +#[test] +fn deleting_a_consented_profile_before_epoch_adoption_preserves_the_tombstone() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("consented-delete.sqlite3")).unwrap(); + let profile = Profile { + id: "consented-profile".into(), + name: "Delete me".into(), + source_path: "/delete/source".into(), + target_path: "/delete/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + database.delete_profile(&profile.id).unwrap(); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000132", false) + .unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 2); + assert_eq!(request["mutations"][1]["kind"], "delete"); + assert_eq!(request["mutations"][1]["profileId"], profile.id); + assert!(database.list_profiles().unwrap().is_empty()); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database.hosted_sync_generation("alice", epoch, "").unwrap(); + database + .apply_hosted_sync_response( + "alice", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "deleted", + "hasMore": false, + "records": [{ "kind": "tombstone", "revision": 2, "profileId": profile.id }], + "receipts": request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + serde_json::json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }), + ) + .unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert!(database.pending_outbox().unwrap().is_empty()); +} + #[test] fn hosted_state_cannot_cross_accounts_and_signout_quarantines_pending_paths() { let directory = tempdir().unwrap(); diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts index 2fd7220..13442ea 100644 --- a/apps/desktop/src/auth.ts +++ b/apps/desktop/src/auth.ts @@ -1,7 +1,7 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { appDataDir, join } from "@tauri-apps/api/path"; -import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { openUrl } from "@tauri-apps/plugin-opener"; import { Stronghold, type Store } from "@tauri-apps/plugin-stronghold"; import { @@ -41,6 +41,7 @@ export interface AuthSnapshot { dataVersion: number; accountClaimRequired?: boolean; epochResetRequired?: boolean; + epochResetPreservesConsentedOutbox?: boolean; error?: string; } @@ -202,6 +203,7 @@ export class DesktopAuthController implements AuthController { private initialized = false; private initializationFlight: { generation: number; promise: Promise } | undefined; private callbackQueue: Promise = Promise.resolve(); + private readonly processedCallbackStates = new Set(); constructor(private readonly config: OidcConfiguration, manager?: UserManager) { if (manager) { @@ -244,23 +246,20 @@ export class DesktopAuthController implements AuthController { } private async initializeOnce(generation: number): Promise { - const user = await this.manager.getUser(); - if (generation !== this.initialization) return; - this.update({ configured: true, loading: false, user: user ? projectUser(user) : null, dataVersion: this.current.dataVersion }); let deepLink: UnlistenFn | undefined; - let singleInstance: UnlistenFn | undefined; try { deepLink = await onOpenUrl((urls) => { for (const url of urls) void this.handleCallback(url); }); if (generation !== this.initialization) return; - singleInstance = await listen("rootline-auth-deep-link", (event) => { void this.handleCallback(event.payload); }); + for (const url of await getCurrent() ?? []) await this.handleCallback(url); + if (generation !== this.initialization) return; + const user = await this.manager.getUser(); if (generation !== this.initialization) return; - this.unlisteners.push(deepLink, singleInstance); + this.update({ configured: true, loading: false, user: user ? projectUser(user) : this.current.user, dataVersion: this.current.dataVersion }); + this.unlisteners.push(deepLink); deepLink = undefined; - singleInstance = undefined; this.initialized = true; } finally { deepLink?.(); - singleInstance?.(); } } @@ -287,6 +286,9 @@ export class DesktopAuthController implements AuthController { private async processCallback(rawUrl: string): Promise { try { const url = validateCallbackUrl(rawUrl); + const state = url.searchParams.get("state")!; + if (this.processedCallbackStates.has(state)) return; + this.processedCallbackStates.add(state); const user = await this.manager.signinRedirectCallback(url.toString()); this.update({ configured: true, loading: false, user: projectUser(user), dataVersion: this.current.dataVersion }); try { await this.sync(); } catch { /* sync() already surfaces reset-required; offline sign-in remains valid */ } @@ -358,13 +360,14 @@ export class DesktopAuthController implements AuthController { await invoke("sync_hosted_profiles", { apiUrl: this.config.apiUrl, accessToken: user.access_token, subject: user.profile.sub }); this.update({ ...this.current, dataVersion: this.current.dataVersion + 1 }); } catch (error) { - const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown } }; + const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown; preservesConsentedOutbox?: unknown } }; if (value?.code === "SYNC_EPOCH_RESET_REQUIRED" && typeof value.details?.epoch === "string") { this.resetEpoch = value.details.epoch; this.update({ ...this.current, loading: false, epochResetRequired: true, + epochResetPreservesConsentedOutbox: value.details.preservesConsentedOutbox === true, error: typeof value.message === "string" ? value.message : "Hosted profile data was reset.", }); } else if (value?.code === "SYNC_ACCOUNT_CLAIM_REQUIRED") { diff --git a/apps/desktop/src/components/AuthControls.tsx b/apps/desktop/src/components/AuthControls.tsx index 411f3d3..2940231 100644 --- a/apps/desktop/src/components/AuthControls.tsx +++ b/apps/desktop/src/components/AuthControls.tsx @@ -40,7 +40,7 @@ export function AuthControls({ auth, coordinator }: { auth: AuthController; coor {choice ? (
-

{choice === "signout" ? "Choose what Rootline keeps on this device." : choice === "delete" ? "Hosted profiles will be permanently deleted and the sync epoch will rotate." : choice === "reset" ? "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded." : "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account."}

+

{choice === "signout" ? "Choose what Rootline keeps on this device." : choice === "delete" ? "Hosted profiles will be permanently deleted and the sync epoch will rotate." : choice === "reset" ? snapshot.epochResetPreservesConsentedOutbox ? "Accept the existing hosted epoch. Explicitly consented local profiles remain queued and will be uploaded; other stale cloud-owned changes are never retained." : "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded." : "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account."}

diff --git a/apps/desktop/src/test/AuthControls.test.tsx b/apps/desktop/src/test/AuthControls.test.tsx index fc3a48c..e9627d6 100644 --- a/apps/desktop/src/test/AuthControls.test.tsx +++ b/apps/desktop/src/test/AuthControls.test.tsx @@ -62,6 +62,27 @@ test("requires an explicit keep-or-remove decision after an epoch reset", async expect(auth.resolveEpochReset).toHaveBeenCalledWith(false); }); +test("explains that accepting an existing epoch keeps explicitly consented device profiles queued", async () => { + const user = userEvent.setup(); + const state = { + configured: true, loading: false, dataVersion: 0, epochResetRequired: true, + epochResetPreservesConsentedOutbox: true, + user: { sub: "device-two", permissions: ["rootline:profiles:sync"] }, + } as AuthSnapshot & { epochResetPreservesConsentedOutbox: boolean }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), + deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), + resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + render(); + await user.click(screen.getByRole("button", { name: "Review reset" })); + expect(screen.getByRole("dialog", { name: "Hosted reset options" })) + .toHaveTextContent(/explicitly consented.*remain queued.*uploaded/i); +}); + test("requires explicit consent before existing absolute-path profiles are claimed by an account", async () => { const user = userEvent.setup(); const state: AuthSnapshot = { diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts index 3769420..1c5183c 100644 --- a/apps/desktop/src/test/auth.test.ts +++ b/apps/desktop/src/test/auth.test.ts @@ -14,14 +14,15 @@ import { } from "../auth"; const tauriMocks = vi.hoisted(() => ({ + getCurrent: vi.fn(async () => null as string[] | null), invoke: vi.fn(), listen: vi.fn(async () => vi.fn()), - onOpenUrl: vi.fn(async () => vi.fn()), + onOpenUrl: vi.fn(async (_handler: (urls: string[]) => void) => vi.fn()), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: tauriMocks.invoke })); vi.mock("@tauri-apps/api/event", () => ({ listen: tauriMocks.listen })); -vi.mock("@tauri-apps/plugin-deep-link", () => ({ onOpenUrl: tauriMocks.onOpenUrl })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ getCurrent: tauriMocks.getCurrent, onOpenUrl: tauriMocks.onOpenUrl })); class MemoryAsyncStorage implements AsyncStorage { private readonly values = new Map(); @@ -44,6 +45,7 @@ describe("Rootline desktop authentication boundary", () => { beforeEach(() => { tauriMocks.invoke.mockReset(); + tauriMocks.getCurrent.mockReset().mockResolvedValue(null); tauriMocks.listen.mockReset().mockResolvedValue(vi.fn()); tauriMocks.onOpenUrl.mockReset().mockResolvedValue(vi.fn()); }); @@ -178,15 +180,55 @@ describe("Rootline desktop authentication boundary", () => { await Promise.all([auth.initialize(), auth.initialize()]); expect(manager.getUser).toHaveBeenCalledTimes(1); expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(1); - expect(tauriMocks.listen).toHaveBeenCalledTimes(1); + expect(tauriMocks.getCurrent).toHaveBeenCalledTimes(1); + expect(tauriMocks.listen).not.toHaveBeenCalled(); await Promise.all([ auth.handleCallback("rootline://auth/callback?code=first&state=one"), - auth.handleCallback("rootline://auth/callback?code=duplicate&state=one"), + auth.handleCallback("rootline://auth/callback?code=first&state=one"), ]); expect(maxCallbacksInFlight).toBe(1); expect(auth.snapshot().user).toEqual(expect.objectContaining({ sub: "alice" })); - expect(auth.snapshot().error).toMatch(/already consumed/); + expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1); + expect(auth.snapshot().error).toBeUndefined(); + }); + + test("installs ingress before vault loading and consumes a cached cold-start callback exactly once", async () => { + const callback = "rootline://auth/callback?code=cold&state=cold-state"; + let releaseUser!: () => void; + const userGate = new Promise((resolve) => { releaseUser = resolve; }); + const storedUser = { + profile: { sub: "cold-user", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + let consumed = false; + const manager = { + getUser: vi.fn(async () => { await userGate; return storedUser; }), + signinRedirectCallback: vi.fn(async () => { + if (consumed) throw new Error("state already consumed"); + consumed = true; + return storedUser; + }), + signinRedirect: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + let liveHandler!: (urls: string[]) => void; + tauriMocks.onOpenUrl.mockImplementation(async (handler: (urls: string[]) => void) => { + liveHandler = handler; + return vi.fn(); + }); + tauriMocks.getCurrent.mockResolvedValue([callback]); + tauriMocks.invoke.mockResolvedValue({}); + const auth = new DesktopAuthController(config, manager as never); + const initialization = auth.initialize(); + await vi.waitFor(() => expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(1)); + expect(manager.getUser).not.toHaveBeenCalled(); + liveHandler([callback]); + releaseUser(); + await initialization; + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + expect(consumed).toBe(true); + expect(auth.snapshot().user).toEqual(expect.objectContaining({ sub: "cold-user" })); + expect(auth.snapshot().error).toBeUndefined(); }); test("surfaces vault/startup and browser launch failures without leaving the offline UI loading", async () => { @@ -205,26 +247,24 @@ describe("Rootline desktop authentication boundary", () => { test("cleans a partial listener registration before retrying initialization", async () => { const firstDeepLinkUnlisten = vi.fn(); const secondDeepLinkUnlisten = vi.fn(); - const singleInstanceUnlisten = vi.fn(); tauriMocks.onOpenUrl .mockResolvedValueOnce(firstDeepLinkUnlisten) .mockResolvedValueOnce(secondDeepLinkUnlisten); - tauriMocks.listen - .mockRejectedValueOnce(new Error("listener unavailable")) - .mockResolvedValueOnce(singleInstanceUnlisten); + tauriMocks.getCurrent + .mockRejectedValueOnce(new Error("cached deep links unavailable")) + .mockResolvedValueOnce(null); const manager = { getUser: vi.fn(async () => null), signinRedirect: vi.fn(), signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), }; const auth = new DesktopAuthController(config, manager as never); - await expect(auth.initialize()).rejects.toThrow(/listener unavailable/); + await expect(auth.initialize()).rejects.toThrow(/cached deep links unavailable/); expect(firstDeepLinkUnlisten).toHaveBeenCalledTimes(1); await auth.initialize(); expect(tauriMocks.onOpenUrl).toHaveBeenCalledTimes(2); - expect(tauriMocks.listen).toHaveBeenCalledTimes(2); + expect(tauriMocks.getCurrent).toHaveBeenCalledTimes(2); auth.dispose(); expect(secondDeepLinkUnlisten).toHaveBeenCalledTimes(1); - expect(singleInstanceUnlisten).toHaveBeenCalledTimes(1); }); test("commits account consent before scheduling best-effort hosted synchronization", async () => { @@ -250,4 +290,30 @@ describe("Rootline desktop authentication boundary", () => { expect(tauriMocks.invoke).toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); releaseSync(); }); + + test("surfaces when epoch adoption preserves explicitly consented unclaimed mutations", async () => { + const storedUser = { + profile: { sub: "device-two", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockRejectedValue({ + code: "SYNC_EPOCH_RESET_REQUIRED", + message: "Existing hosted account epoch found.", + details: { + epoch: "00000000-0000-4000-8000-000000000123", + preservesConsentedOutbox: true, + }, + }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await expect(auth.sync()).rejects.toEqual(expect.objectContaining({ code: "SYNC_EPOCH_RESET_REQUIRED" })); + expect(auth.snapshot()).toEqual(expect.objectContaining({ + epochResetRequired: true, + epochResetPreservesConsentedOutbox: true, + })); + }); }); diff --git a/apps/desktop/src/test/stronghold-storage.test.ts b/apps/desktop/src/test/stronghold-storage.test.ts index 2cafbdd..99a1953 100644 --- a/apps/desktop/src/test/stronghold-storage.test.ts +++ b/apps/desktop/src/test/stronghold-storage.test.ts @@ -13,7 +13,7 @@ vi.mock("@tauri-apps/api/path", () => ({ join: vi.fn(async (...parts: string[]) => parts.join("/")), })); vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); -vi.mock("@tauri-apps/plugin-deep-link", () => ({ onOpenUrl: vi.fn() })); +vi.mock("@tauri-apps/plugin-deep-link", () => ({ getCurrent: vi.fn(), onOpenUrl: vi.fn() })); vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); vi.mock("@tauri-apps/plugin-stronghold", () => ({ Stronghold: { load: mocks.load }, diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 1bdc1e3..faad103 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -18,7 +18,7 @@ Production registration is an external deployment gate. Create an Authentik OAut | **Permission claim** | `rootline:profiles:sync` in the `permissions` array | | **Signing algorithm** | RS256 | -Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. +Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. The Tauri deep-link listener is installed before vault loading, cached cold-start URLs are read with `getCurrent`, and each callback state is consumed once. The deep-link plugin is the only URL delivery path, including Windows single-instance forwarding. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. Set all three desktop build variables together: @@ -32,7 +32,9 @@ When all are absent, account controls are disabled and offline use continues. If Profiles saved before the first sign-in remain unclaimed. Because they contain absolute paths, Rootline requires an explicit **Upload existing profiles** or **Keep local only** decision before binding their outbox to an OIDC subject. Signing out always removes that subject's cursor and queued mutations; choosing to keep local profiles does not make them eligible for a later account automatically. A different account therefore cannot inherit the previous account's paths or cursor. -Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, and session generation; the response must still match all four values inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance advance the generation, so delayed or out-of-order responses are discarded. Repeating an already committed claim for the same subject is idempotent. +If a second device explicitly consents to upload pre-login profiles but discovers an existing server epoch, accepting that epoch preserves only those consented, not-yet-cloud-owned mutation chains. Each mutation keeps that provenance until its own successful receipt. An edit or deletion of the same consented profile before adoption inherits the marker, preserving order and preventing an older upsert from overwriting or resurrecting it; unrelated edits queued after account binding are cloud-owned and are discarded by a later reset. Once every consented chain is acknowledged, later reset adoption clears the outbox so deleted hosted data cannot be resurrected. + +Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, and session generation; the response must still match all four values inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, epoch acceptance, and local profile save/delete transactions advance the generation, so delayed or out-of-order records cannot overwrite or resurrect newer local state. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. A generation change makes the active loop discard that response and replay the still-queued mutation. Repeating an already committed claim for the same subject is idempotent. ## API deployment @@ -83,6 +85,14 @@ For a reproducible local integration run, Docker can provision a disposable Post pnpm --filter @rootline/api test:e2e:postgres ``` +The root test command uses that same provision/migrate/test/cleanup harness automatically and needs no pre-existing `DATABASE_URL`: + +```bash +pnpm test +``` + +The API suite includes a real seam test that starts from the desktop's SQLite profile/outbox, adopts an existing device-one server epoch, reconnects through NestJS/PostgreSQL, verifies receipts, and proves the absolute path is not sent to a different OIDC subject. + ## Operator validation - [ ] Authentik registration is a public client with the exact redirect URI and scopes. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2db151b..cea97c1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -100,6 +100,7 @@ export const ROOTLINE_ERROR_CODES = { PROFILE_CONFLICT: "PROFILE_CONFLICT", SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", + SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index 7eb75fb..fb1b548 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -20,6 +20,7 @@ describe("Rootline contracts", () => { PROFILE_CONFLICT: "PROFILE_CONFLICT", SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", + SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts index 21c79c1..b9144ce 100644 --- a/packages/contracts/test/contracts.types.test.ts +++ b/packages/contracts/test/contracts.types.test.ts @@ -45,6 +45,7 @@ test("public domain and cloud contracts retain their transport shapes", () => { | "PROFILE_CONFLICT" | "SYNC_EPOCH_RESET_REQUIRED" | "SYNC_ACCOUNT_CLAIM_REQUIRED" + | "SYNC_STATE_CHANGED" | "RATE_LIMITED" | "VALIDATION_FAILED" | "INTERNAL" From db0fe2bf99661511127596a7920667f29d99c8de Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 07:45:46 +0700 Subject: [PATCH 14/26] fix: stop stale account sync retries --- .../0005_sync_lifecycle_generation.sql | 2 + apps/desktop/src-tauri/src/lib.rs | 803 ++++++++++++++---- docs/hosted-profile-sync.md | 2 +- 3 files changed, 647 insertions(+), 160 deletions(-) create mode 100644 apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql diff --git a/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql b/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql new file mode 100644 index 0000000..56294bf --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0005_sync_lifecycle_generation.sql @@ -0,0 +1,2 @@ +ALTER TABLE sync_state ADD COLUMN lifecycle_generation TEXT NOT NULL DEFAULT ''; +UPDATE sync_state SET lifecycle_generation=lower(hex(randomblob(16))); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ddc5a4f..9f65e6e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -698,6 +698,10 @@ const MIGRATIONS: &[(i64, &str)] = &[ 4, include_str!("../migrations/0004_consented_epoch_adoption.sql"), ), + ( + 5, + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + ), ]; const HOSTED_SYNC_MUTATION_LIMIT: usize = 100; const HOSTED_SYNC_BODY_LIMIT: usize = 256 * 1024; @@ -890,10 +894,13 @@ impl Database { } pub fn set_sync_cursor(&self, epoch: &str, cursor: &str) -> Result<(), NativeError> { + let lifecycle_generation = Uuid::new_v4().to_string(); self.connection().execute( - "INSERT INTO sync_state(singleton, epoch, cursor, subject) VALUES (1, ?1, ?2, '') - ON CONFLICT(singleton) DO UPDATE SET epoch=excluded.epoch, cursor=excluded.cursor", - params![epoch, cursor], + "INSERT INTO sync_state(singleton, epoch, cursor, subject, lifecycle_generation) + VALUES (1, ?1, ?2, '', ?3) + ON CONFLICT(singleton) DO UPDATE SET epoch=excluded.epoch, cursor=excluded.cursor, + lifecycle_generation=excluded.lifecycle_generation", + params![epoch, cursor, lifecycle_generation], )?; Ok(()) } @@ -920,23 +927,6 @@ impl Database { .optional()?) } - fn set_sync_binding( - &self, - subject: &str, - epoch: &str, - cursor: &str, - ) -> Result<(), NativeError> { - self.connection().execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) - VALUES (1, ?1, ?2, ?3, 1, 0) - ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, - cursor=excluded.cursor, session_generation=sync_state.session_generation + 1, - preserve_outbox_on_epoch_adopt=0", - params![subject, epoch, cursor], - )?; - Ok(()) - } - pub fn clear_local_synced_data(&self) -> Result<(), NativeError> { let mut connection = self.connection(); let transaction = connection.transaction()?; @@ -953,13 +943,16 @@ impl Database { ) -> Result<(), NativeError> { let mut connection = self.connection(); let transaction = connection.transaction()?; + let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute("DELETE FROM mutation_outbox", [])?; transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) - VALUES (1, '', '', '', 1, 0) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, '', '', '', 1, 0, ?1) ON CONFLICT(singleton) DO UPDATE SET subject='', epoch='', cursor='', - session_generation=sync_state.session_generation + 1, preserve_outbox_on_epoch_adopt=0", - [], + session_generation=sync_state.session_generation + 1, preserve_outbox_on_epoch_adopt=0, + lifecycle_generation=excluded.lifecycle_generation", + [lifecycle_generation], )?; if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; @@ -1007,13 +1000,21 @@ impl Database { transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; } let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) - VALUES (1, ?1, ?2, '', 1, ?3) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, ?1, ?2, '', 1, ?3, ?4) ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, cursor='', session_generation=sync_state.session_generation + 1, - preserve_outbox_on_epoch_adopt=excluded.preserve_outbox_on_epoch_adopt", - params![subject, epoch, i64::from(upload_existing)], + preserve_outbox_on_epoch_adopt=excluded.preserve_outbox_on_epoch_adopt, + lifecycle_generation=excluded.lifecycle_generation", + params![ + subject, + epoch, + i64::from(upload_existing), + lifecycle_generation + ], )?; transaction.commit()?; Ok(()) @@ -1050,13 +1051,21 @@ impl Database { if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; } + let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( - "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt) - VALUES (1, ?1, ?2, '', 1, 0) + "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation) + VALUES (1, ?1, ?2, '', 1, 0, ?4) ON CONFLICT(singleton) DO UPDATE SET subject=excluded.subject, epoch=excluded.epoch, cursor='', session_generation=sync_state.session_generation + 1, - preserve_outbox_on_epoch_adopt=CASE WHEN ?3 THEN 1 ELSE 0 END", - params![subject, epoch, preserve_consented_outbox], + preserve_outbox_on_epoch_adopt=CASE WHEN ?3 THEN 1 ELSE 0 END, + lifecycle_generation=excluded.lifecycle_generation", + params![ + subject, + epoch, + preserve_consented_outbox, + lifecycle_generation + ], )?; transaction.commit()?; Ok(()) @@ -1112,62 +1121,139 @@ impl Database { if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; } + let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( "UPDATE sync_state SET epoch=?1, cursor='', session_generation=session_generation + 1, - preserve_outbox_on_epoch_adopt=0 + preserve_outbox_on_epoch_adopt=0, lifecycle_generation=?6 WHERE singleton=1 AND subject=?2 AND epoch=?3 AND cursor=?4 AND session_generation=?5", params![ next_epoch, expected_subject, expected_epoch, expected_cursor, - expected_generation + expected_generation, + lifecycle_generation ], )?; transaction.commit()?; Ok(()) } - pub fn hosted_sync_request(&self, subject: &str) -> Result { - let device_id = self.device_id()?; - let has_unclaimed_mutations = !self.pending_outbox()?.is_empty(); - let (epoch, cursor) = match self.sync_binding()? { - Some((owner, epoch, cursor, _)) if owner == subject => (epoch, cursor), - Some((owner, _, _, _)) if owner.is_empty() => { - if has_unclaimed_mutations { - return Err(NativeError::new( - NativeErrorCode::SyncAccountClaimRequired, - "Choose whether this account may upload existing local profiles.", - )); - } - let epoch = Uuid::new_v4().to_string(); - self.set_sync_binding(subject, &epoch, "")?; - (epoch, String::new()) - } - Some(_) => { - return Err(NativeError::new( - NativeErrorCode::AuthRequired, - "Local hosted-sync state belongs to another account. Sign out before switching accounts.", - )) - } + fn build_hosted_sync_request( + &self, + subject: &str, + expected_lifecycle_generation: Option<&str>, + ) -> Result<(serde_json::Value, String), NativeError> { + let connection = self.connection(); + let device_id: String = match connection + .query_row( + "SELECT value FROM settings WHERE key='device_id'", + [], + |row| row.get(0), + ) + .optional()? + { + Some(device_id) => device_id, None => { - if has_unclaimed_mutations { + let device_id = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO settings(key, value) VALUES ('device_id', ?1)", + [&device_id], + )?; + device_id + } + }; + let pending = { + let mut statement = connection.prepare( + "SELECT mutation_id, kind, payload, occurred_at + FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(OutboxMutation { + mutation_id: row.get(0)?, + kind: row.get(1)?, + payload: row.get(2)?, + occurred_at: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + }; + let current: Option<(String, String, String, String)> = connection + .query_row( + "SELECT subject, epoch, cursor, lifecycle_generation + FROM sync_state WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let (epoch, cursor, lifecycle_generation) = if let Some(expected_lifecycle_generation) = + expected_lifecycle_generation + { + match current { + Some((owner, epoch, cursor, lifecycle_generation)) + if owner == subject + && lifecycle_generation == expected_lifecycle_generation => + { + (epoch, cursor, lifecycle_generation) + } + _ => { return Err(NativeError::new( - NativeErrorCode::SyncAccountClaimRequired, - "Choose whether this account may upload existing local profiles.", - )); + NativeErrorCode::SyncStateChanged, + "Hosted sync lifecycle changed; the stale request was stopped.", + )) } - let epoch = Uuid::new_v4().to_string(); - self.set_sync_binding(subject, &epoch, "")?; - (epoch, String::new()) } + } else { + match current { + Some((owner, epoch, cursor, lifecycle_generation)) if owner == subject => { + (epoch, cursor, lifecycle_generation) + } + Some((owner, _, _, _)) if owner.is_empty() => { + if !pending.is_empty() { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } + let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); + connection.execute( + "UPDATE sync_state SET subject=?1, epoch=?2, cursor='', + session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=0, lifecycle_generation=?3 + WHERE singleton=1", + params![subject, epoch, lifecycle_generation], + )?; + (epoch, String::new(), lifecycle_generation) + } + Some(_) => { + return Err(NativeError::new( + NativeErrorCode::AuthRequired, + "Local hosted-sync state belongs to another account. Sign out before switching accounts.", + )) + } + None => { + if !pending.is_empty() { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } + let epoch = Uuid::new_v4().to_string(); + let lifecycle_generation = Uuid::new_v4().to_string(); + connection.execute( + "INSERT INTO sync_state( + singleton, subject, epoch, cursor, session_generation, + preserve_outbox_on_epoch_adopt, lifecycle_generation + ) VALUES (1, ?1, ?2, '', 1, 0, ?3)", + params![subject, epoch, lifecycle_generation], + )?; + (epoch, String::new(), lifecycle_generation) + } + } }; let mut mutations = Vec::new(); - for mutation in self - .pending_outbox()? - .into_iter() - .take(HOSTED_SYNC_MUTATION_LIMIT) - { + for mutation in pending.into_iter().take(HOSTED_SYNC_MUTATION_LIMIT) { let payload: serde_json::Value = serde_json::from_str(&mutation.payload).map_err(internal_error)?; let value = if mutation.kind == "upsert" { @@ -1212,7 +1298,27 @@ impl Database { if !cursor.is_empty() { request["cursor"] = serde_json::Value::String(cursor); } - Ok(request) + Ok((request, lifecycle_generation)) + } + + fn initial_hosted_sync_request( + &self, + subject: &str, + ) -> Result<(serde_json::Value, String), NativeError> { + self.build_hosted_sync_request(subject, None) + } + + fn hosted_sync_request_for_lifecycle( + &self, + subject: &str, + lifecycle_generation: &str, + ) -> Result<(serde_json::Value, String), NativeError> { + self.build_hosted_sync_request(subject, Some(lifecycle_generation)) + } + + pub fn hosted_sync_request(&self, subject: &str) -> Result { + self.initial_hosted_sync_request(subject) + .map(|(request, _)| request) } pub fn hosted_sync_generation( @@ -1234,6 +1340,26 @@ impl Database { }) } + fn is_hosted_sync_lifecycle_current( + &self, + subject: &str, + epoch: &str, + cursor: &str, + lifecycle_generation: &str, + ) -> Result { + Ok(self + .connection() + .query_row( + "SELECT 1 FROM sync_state + WHERE singleton=1 AND subject=?1 AND epoch=?2 AND cursor=?3 + AND lifecycle_generation=?4", + params![subject, epoch, cursor, lifecycle_generation], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + pub fn apply_hosted_sync_response( &self, expected_subject: &str, @@ -1548,104 +1674,55 @@ struct HostedSyncOutcome { #[derive(Default)] struct HostedSyncLock(tokio::sync::Mutex<()>); -#[tauri::command] -async fn sync_hosted_profiles( - api_url: String, - access_token: String, - subject: String, - database: State<'_, Database>, - sync_lock: State<'_, HostedSyncLock>, -) -> Result { - let _sync_guard = sync_lock.0.lock().await; - let base = url::Url::parse(&api_url).map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync configuration is invalid.", - ) - })?; - if base.scheme() != "https" || base.host_str().is_none() { - return Err(NativeError::new( - NativeErrorCode::Internal, - "Hosted sync requires an HTTPS endpoint.", - )); - } - let endpoint = base.join("/v1/sync").map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync configuration is invalid.", - ) - })?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync is unavailable; local data remains safe.", - ) - })?; +async fn run_hosted_sync_loop( + database: &Database, + subject: &str, + mut send: Send, +) -> Result +where + Send: FnMut(serde_json::Value) -> Response, + Response: + std::future::Future>, +{ let mut acknowledged = 0; let mut records_applied = 0; + let mut lifecycle_generation: Option = None; let cursor = loop { - let payload = database.hosted_sync_request(&subject)?; + let (payload, request_lifecycle_generation) = if let Some(expected_lifecycle_generation) = + lifecycle_generation.as_deref() + { + database.hosted_sync_request_for_lifecycle(subject, expected_lifecycle_generation)? + } else { + database.initial_hosted_sync_request(subject)? + }; let expected_epoch = payload["epoch"].as_str().unwrap_or_default().to_owned(); let expected_cursor = payload["cursor"].as_str().unwrap_or_default().to_owned(); + lifecycle_generation = Some(request_lifecycle_generation.clone()); let expected_generation = - database.hosted_sync_generation(&subject, &expected_epoch, &expected_cursor)?; + database.hosted_sync_generation(subject, &expected_epoch, &expected_cursor)?; let sent_ids: HashSet = payload["mutations"] .as_array() .into_iter() .flatten() .filter_map(|mutation| mutation["mutationId"].as_str().map(ToOwned::to_owned)) .collect(); - let mut response = client - .post(endpoint.clone()) - .bearer_auth(&access_token) - .json(&payload) - .send() - .await - .map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync is unavailable; local data remains safe.", - ) - })?; - let status = response.status(); - if response - .content_length() - .is_some_and(|length| length > 2 * 1024 * 1024) - { + let (status, body) = send(payload).await?; + if !database.is_hosted_sync_lifecycle_current( + subject, + &expected_epoch, + &expected_cursor, + &request_lifecycle_generation, + )? { return Err(NativeError::new( - NativeErrorCode::Internal, - "Hosted sync returned an oversized response.", + NativeErrorCode::SyncStateChanged, + "Hosted sync lifecycle changed; the stale response was stopped.", )); } - let mut response_bytes = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync returned an invalid response.", - ) - })? { - if response_bytes.len() + chunk.len() > 2 * 1024 * 1024 { - return Err(NativeError::new( - NativeErrorCode::Internal, - "Hosted sync returned an oversized response.", - )); - } - response_bytes.extend_from_slice(&chunk); - } - let body: serde_json::Value = serde_json::from_slice(&response_bytes).map_err(|_| { - NativeError::new( - NativeErrorCode::Internal, - "Hosted sync returned an invalid response.", - ) - })?; if status == reqwest::StatusCode::CONFLICT && body.get("code").and_then(serde_json::Value::as_str) == Some("SYNC_EPOCH_RESET_REQUIRED") { - let preserves_consented_outbox = database.preserves_consented_outbox(&subject)?; + let preserves_consented_outbox = database.preserves_consented_outbox(subject)?; return Err(NativeError { code: NativeErrorCode::SyncEpochResetRequired, message: if preserves_consented_outbox { @@ -1654,15 +1731,12 @@ async fn sync_hosted_profiles( "Hosted profile data was reset. Review this device before uploading again." } .into(), - details: body - .get("epoch") - .cloned() - .map(|epoch| { - json!({ - "epoch": epoch, - "preservesConsentedOutbox": preserves_consented_outbox - }) - }), + details: body.get("epoch").cloned().map(|epoch| { + json!({ + "epoch": epoch, + "preservesConsentedOutbox": preserves_consented_outbox + }) + }), }); } if !status.is_success() { @@ -1681,14 +1755,24 @@ async fn sync_hosted_profiles( .unwrap_or_default() .to_owned(); match database.apply_hosted_sync_response( - &subject, + subject, &expected_epoch, &expected_cursor, expected_generation, &body, ) { Ok(()) => {} - Err(error) if error.code == NativeErrorCode::SyncStateChanged => continue, + Err(error) if error.code == NativeErrorCode::SyncStateChanged => { + if database.is_hosted_sync_lifecycle_current( + subject, + &expected_epoch, + &expected_cursor, + &request_lifecycle_generation, + )? { + continue; + } + return Err(error); + } Err(error) => return Err(error), } acknowledged += body @@ -1732,6 +1816,96 @@ async fn sync_hosted_profiles( }) } +#[tauri::command] +async fn sync_hosted_profiles( + api_url: String, + access_token: String, + subject: String, + database: State<'_, Database>, + sync_lock: State<'_, HostedSyncLock>, +) -> Result { + let _sync_guard = sync_lock.0.lock().await; + let base = url::Url::parse(&api_url).map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync configuration is invalid.", + ) + })?; + if base.scheme() != "https" || base.host_str().is_none() { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync requires an HTTPS endpoint.", + )); + } + let endpoint = base.join("/v1/sync").map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync configuration is invalid.", + ) + })?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync is unavailable; local data remains safe.", + ) + })?; + run_hosted_sync_loop(&database, &subject, move |payload| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let access_token = access_token.clone(); + async move { + let mut response = client + .post(endpoint) + .bearer_auth(access_token) + .json(&payload) + .send() + .await + .map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync is unavailable; local data remains safe.", + ) + })?; + let status = response.status(); + if response + .content_length() + .is_some_and(|length| length > 2 * 1024 * 1024) + { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an oversized response.", + )); + } + let mut response_bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid response.", + ) + })? { + if response_bytes.len() + chunk.len() > 2 * 1024 * 1024 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an oversized response.", + )); + } + response_bytes.extend_from_slice(&chunk); + } + let body = serde_json::from_slice(&response_bytes).map_err(|_| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid response.", + ) + })?; + Ok((status, body)) + } + }) + .await +} + #[tauri::command] fn clear_local_synced_data(database: State<'_, Database>) -> Result<(), NativeError> { database.clear_local_synced_data() @@ -1909,7 +2083,9 @@ pub fn run() { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::tempdir; + use tokio::sync::oneshot; #[test] fn mkdir_reports_already_existing_directories() { @@ -1969,4 +2145,313 @@ mod tests { assert!(target.path().join("first").is_dir()); assert!(!target.path().join("second").exists()); } + + #[test] + fn command_loop_stops_after_disconnect_and_allows_bob_without_rebinding_alice() { + for stale_status in [reqwest::StatusCode::OK, reqwest::StatusCode::CONFLICT] { + for remove_local_profiles in [false, true] { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join(format!( + "disconnect-command-loop-{remove_local_profiles}-{}.sqlite3", + stale_status.as_u16() + ))) + .unwrap(), + ); + let profile = Profile { + id: "alice-local".into(), + name: "Alice local".into(), + source_path: "/alice/private".into(), + target_path: "/alice/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + + let alice_sends = Arc::new(AtomicUsize::new(0)); + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (response_tx, response_rx) = oneshot::channel(); + let task_database = Arc::clone(&database); + let task_sends = Arc::clone(&alice_sends); + let sync = tauri::async_runtime::spawn(async move { + let mut request_started_tx = Some(request_started_tx); + let mut response_rx = Some(response_rx); + run_hosted_sync_loop(&task_database, "alice", move |payload| { + task_sends.fetch_add(1, Ordering::SeqCst); + let started = request_started_tx.take(); + let response = response_rx.take(); + async move { + if let Some(started) = started { + let _ = started.send(payload); + } + match response { + Some(response) => response.await.unwrap(), + None => Err(NativeError::new( + NativeErrorCode::Internal, + "Alice transport was reused after disconnect.", + )), + } + } + }) + .await + }); + + let alice_request = request_started_rx.await.unwrap(); + database + .disconnect_hosted_account(remove_local_profiles) + .unwrap(); + response_tx + .send(Ok(( + stale_status, + if stale_status == reqwest::StatusCode::CONFLICT { + json!({ + "code": "SYNC_EPOCH_RESET_REQUIRED", + "epoch": alice_request["epoch"] + }) + } else { + json!({ + "epoch": alice_request["epoch"], + "cursor": "alice-stale", + "hasMore": false, + "records": [], + "receipts": alice_request["mutations"].as_array().unwrap().iter().enumerate().map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }).collect::>() + }) + }, + ))) + .unwrap(); + let error = sync.await.unwrap().unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(alice_sends.load(Ordering::SeqCst), 1); + + database.claim_hosted_account("bob", false).unwrap(); + let bob_sends = Arc::new(AtomicUsize::new(0)); + let counted_bob_sends = Arc::clone(&bob_sends); + run_hosted_sync_loop(&database, "bob", move |payload| { + counted_bob_sends.fetch_add(1, Ordering::SeqCst); + async move { + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "bob-current", + "hasMore": false, + "records": [], + "receipts": [] + }), + )) + } + }) + .await + .unwrap(); + assert_eq!(bob_sends.load(Ordering::SeqCst), 1); + assert_eq!(database.sync_cursor().unwrap().unwrap().1, "bob-current"); + }); + } + } + } + + #[test] + fn strict_followup_request_cannot_rebind_after_preflight_disconnect_interleaving() { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("strict-followup-request.sqlite3")).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + let (_, lifecycle_generation) = database.initial_hosted_sync_request("alice").unwrap(); + + database.disconnect_hosted_account(false).unwrap(); + let error = database + .hosted_sync_request_for_lifecycle("alice", &lifecycle_generation) + .unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert!(database.hosted_sync_request("bob").is_ok()); + } + + #[test] + fn command_loop_preflights_lifecycle_before_building_a_followup_page() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("post-success-race.sqlite3")).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + database + .connection() + .execute_batch( + "CREATE TRIGGER disconnect_after_page_one + AFTER UPDATE OF cursor ON sync_state + WHEN NEW.cursor='page-one' + BEGIN + DELETE FROM mutation_outbox; + UPDATE sync_state SET subject='', epoch='', cursor='', + session_generation=session_generation + 1, + lifecycle_generation=lower(hex(randomblob(16))) + WHERE singleton=1; + END;", + ) + .unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let counted_sends = Arc::clone(&sends); + let error = run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + async move { + if attempt > 0 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Alice follow-up transport ran after lifecycle invalidation.", + )); + } + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "page-one", + "hasMore": true, + "records": [], + "receipts": [] + }), + )) + } + }) + .await + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(sends.load(Ordering::SeqCst), 1); + assert!(database.hosted_sync_request("bob").is_ok()); + }); + } + + #[test] + fn command_loop_rejects_same_account_aba_after_disconnect_and_rebind() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join("same-account-aba.sqlite3")).unwrap(), + ); + database.claim_hosted_account("alice", false).unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (response_tx, response_rx) = oneshot::channel(); + let task_database = Arc::clone(&database); + let task_sends = Arc::clone(&sends); + let sync = tauri::async_runtime::spawn(async move { + let mut request_started_tx = Some(request_started_tx); + let mut response_rx = Some(response_rx); + run_hosted_sync_loop(&task_database, "alice", move |payload| { + task_sends.fetch_add(1, Ordering::SeqCst); + let started = request_started_tx.take(); + let response = response_rx.take(); + async move { + if let Some(started) = started { + let _ = started.send(payload); + } + match response { + Some(response) => response.await.unwrap(), + None => Err(NativeError::new( + NativeErrorCode::Internal, + "Old Alice transport was reused after account ABA.", + )), + } + } + }) + .await + }); + + let request = request_started_rx.await.unwrap(); + let old_epoch = request["epoch"].as_str().unwrap(); + database.disconnect_hosted_account(false).unwrap(); + database.claim_hosted_account("alice", false).unwrap(); + database + .accept_account_epoch("alice", old_epoch, false) + .unwrap(); + response_tx + .send(Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": old_epoch, + "cursor": "stale-aba", + "hasMore": false, + "records": [], + "receipts": [] + }), + ))) + .unwrap(); + + let error = sync.await.unwrap().unwrap_err(); + assert_eq!(error.code, NativeErrorCode::SyncStateChanged); + assert_eq!(sends.load(Ordering::SeqCst), 1); + }); + } + + #[test] + fn command_loop_retries_a_same_subject_edit_generation() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Arc::new( + Database::open(directory.path().join("edit-command-loop.sqlite3")).unwrap(), + ); + let profile = Profile { + id: "alice-edit".into(), + name: "Before request".into(), + source_path: "/alice/source".into(), + target_path: "/alice/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&profile).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let transport_database = Arc::clone(&database); + let counted_sends = Arc::clone(&sends); + let edited_profile = Profile { + name: "Edited during request".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..profile + }; + + run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + let transport_database = Arc::clone(&transport_database); + let edited_profile = edited_profile.clone(); + async move { + if attempt == 0 { + transport_database.save_profile(&edited_profile).unwrap(); + } + let receipts = payload["mutations"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }) + .collect::>(); + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": format!("edit-attempt-{attempt}"), + "hasMore": false, + "records": [], + "receipts": receipts + }), + )) + } + }) + .await + .unwrap(); + + assert_eq!(sends.load(Ordering::SeqCst), 2); + assert_eq!( + database.list_profiles().unwrap()[0].name, + "Edited during request" + ); + assert!(database.pending_outbox().unwrap().is_empty()); + }); + } } diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index faad103..8fb3165 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -34,7 +34,7 @@ Profiles saved before the first sign-in remain unclaimed. Because they contain a If a second device explicitly consents to upload pre-login profiles but discovers an existing server epoch, accepting that epoch preserves only those consented, not-yet-cloud-owned mutation chains. Each mutation keeps that provenance until its own successful receipt. An edit or deletion of the same consented profile before adoption inherits the marker, preserving order and preventing an older upsert from overwriting or resurrecting it; unrelated edits queued after account binding are cloud-owned and are discarded by a later reset. Once every consented chain is acknowledged, later reset adoption clears the outbox so deleted hosted data cannot be resurrected. -Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, and session generation; the response must still match all four values inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, epoch acceptance, and local profile save/delete transactions advance the generation, so delayed or out-of-order records cannot overwrite or resurrect newer local state. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. A generation change makes the active loop discard that response and replay the still-queued mutation. Repeating an already committed claim for the same subject is idempotent. +Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, mutation generation, and a random lifecycle generation. The response must still match that state inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance rotate the lifecycle generation; local profile save/delete changes only the mutation generation. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. Every follow-up request is constructed under one database critical section that requires the captured lifecycle and prohibits auto-binding; lifecycle is checked again immediately after transport, before interpreting even a reset response. The loop retries a rejected response only while that lifecycle remains current. This permits same-session local-edit replay while sign-out, removal, account switch, epoch adoption, and same-account ABA stop without rebuilding a stale request, rebinding the old subject, or reusing its captured token. Repeating an already committed claim for the same subject is idempotent. ## API deployment From a4775c77fe71881f58e9092b44f1bd07f414c87d Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 08:46:24 +0700 Subject: [PATCH 15/26] feat: harden Rootline release pipeline --- .dockerignore | 9 + .github/workflows/ci.yml | 126 +++ .github/workflows/release-api.yml | 217 +++++ .github/workflows/release-desktop.yml | 231 +++++ .github/workflows/release-npm.yml | 103 ++ CHANGELOG.md | 20 +- CONTRIBUTING.md | 97 +- EXAMPLES.md | 213 +--- PUBLISHING.md | 329 +------ QUICK-REFERENCE.md | 85 +- README.md | 320 +----- SECURITY.md | 59 +- apps/api/Dockerfile | 39 + apps/api/src/health.controller.ts | 2 +- apps/api/test/sync.e2e.spec.ts | 2 +- apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 219 ++++- apps/desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/default.json | 3 +- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/tauri.conf.json | 7 + apps/desktop/src/auth.ts | 1 - docs/README.md | 29 +- docs/architecture.md | 45 + docs/baseline/npm-1.1.0-recovery.md | 1 + docs/configuration.md | 58 ++ docs/hosted-profile-sync.md | 6 +- docs/migration-v1-to-v2.md | 47 + docs/operations.md | 57 ++ docs/privacy.md | 35 + docs/release.md | 60 ++ eslint.config.mjs | 24 + package.json | 11 +- packages/cli/LICENSE | 15 + packages/cli/README.md | 14 + packages/cli/package.json | 16 +- packages/cli/test/package-smoke.test.ts | 25 +- pnpm-lock.yaml | 919 +++++++++++++++++- scripts/create-updater-manifest.mjs | 32 + tests/release-workflows.test.mjs | 165 ++++ tests/updater-manifest.test.mjs | 39 + 41 files changed, 2675 insertions(+), 1008 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-api.yml create mode 100644 .github/workflows/release-desktop.yml create mode 100644 .github/workflows/release-npm.yml create mode 100644 apps/api/Dockerfile create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/migration-v1-to-v2.md create mode 100644 docs/operations.md create mode 100644 docs/privacy.md create mode 100644 docs/release.md create mode 100644 eslint.config.mjs create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/README.md create mode 100644 scripts/create-updater-manifest.mjs create mode 100644 tests/release-workflows.test.mjs create mode 100644 tests/updater-manifest.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9fb5c6b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.superpowers +.worktrees +**/dist +**/node_modules +apps/desktop/src-tauri/target +docs +tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..78ec3eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +"on": + pull_request: + push: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript-quality: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test:unit + - run: pnpm build + - run: pnpm test:release + + npm-tarball-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter folder-structure-sync test:pack + + postgres-integration: + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: rootline + POSTGRES_PASSWORD: rootline + POSTGRES_DB: rootline_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U rootline -d rootline_test" + --health-interval 2s + --health-timeout 5s + --health-retries 30 + env: + DATABASE_URL: postgresql://rootline:rootline@127.0.0.1:5432/rootline_test?schema=public + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install Tauri system dependencies for the native PostgreSQL seam + run: sudo apt-get update && sudo apt-get install --yes libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @rootline/api prisma:generate + - run: pnpm --filter @rootline/api prisma:migrate:deploy + - run: pnpm --filter @rootline/api test:e2e + + rust: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Install Tauri system dependencies + run: sudo apt-get update && sudo apt-get install --yes libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: cargo fmt --check --manifest-path apps/desktop/src-tauri/Cargo.toml + - run: cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + - run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml + + tauri-build: + name: Tauri ${{ matrix.label }} + strategy: + fail-fast: false + matrix: + include: + - label: macOS Universal + runner: macos-15 + target: universal-apple-darwin + - label: Windows x64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - label: Windows ARM64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target == 'universal-apple-darwin' && 'aarch64-apple-darwin,x86_64-apple-darwin' || matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @rootline/desktop tauri build --target ${{ matrix.target }} --no-bundle diff --git a/.github/workflows/release-api.yml b/.github/workflows/release-api.yml new file mode 100644 index 0000000..b638908 --- /dev/null +++ b/.github/workflows/release-api.yml @@ -0,0 +1,217 @@ +name: Release Rootline API 2.0.0 + +"on": + workflow_dispatch: + inputs: + confirmation: + description: Type release-api-v2.0.0 to deploy the stable API + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-api-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: api-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Require exact version and every production deployment secret + env: + CONFIRMATION: ${{ inputs.confirmation }} + ROOTLINE_API_DATABASE_URL: ${{ secrets.ROOTLINE_API_DATABASE_URL }} + ROOTLINE_API_DEPLOY_WEBHOOK_URL: ${{ secrets.ROOTLINE_API_DEPLOY_WEBHOOK_URL }} + ROOTLINE_API_DEPLOY_TOKEN: ${{ secrets.ROOTLINE_API_DEPLOY_TOKEN }} + ROOTLINE_API_BASE_URL: ${{ secrets.ROOTLINE_API_BASE_URL }} + ROOTLINE_JWT_ISSUER: ${{ secrets.ROOTLINE_JWT_ISSUER }} + ROOTLINE_JWT_AUDIENCE: ${{ secrets.ROOTLINE_JWT_AUDIENCE }} + ROOTLINE_JWT_JWKS_B64: ${{ secrets.ROOTLINE_JWT_JWKS_B64 }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable API release is restricted to the existing tag refs/tags/v2.0.0." + exit 1 + fi + if [ "${CONFIRMATION}" != "release-api-v2.0.0" ]; then + echo "::error::Stable API release blocked. Re-run with confirmation=release-api-v2.0.0." + exit 1 + fi + missing="" + for name in ROOTLINE_API_DATABASE_URL ROOTLINE_API_DEPLOY_WEBHOOK_URL ROOTLINE_API_DEPLOY_TOKEN ROOTLINE_API_BASE_URL ROOTLINE_JWT_ISSUER ROOTLINE_JWT_AUDIENCE ROOTLINE_JWT_JWKS_B64; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Stable API release blocked. Configure repository environment secrets:$missing" + exit 1 + fi + case "${ROOTLINE_API_BASE_URL}" in https://*) ;; *) echo "::error::ROOTLINE_API_BASE_URL must use HTTPS."; exit 1 ;; esac + case "${ROOTLINE_API_DEPLOY_WEBHOOK_URL}" in https://*) ;; *) echo "::error::ROOTLINE_API_DEPLOY_WEBHOOK_URL must use HTTPS."; exit 1 ;; esac + node <<'NODE' + const database = new URL(process.env.ROOTLINE_API_DATABASE_URL); + if (!["postgres:", "postgresql:"].includes(database.protocol)) throw new Error("ROOTLINE_API_DATABASE_URL must be PostgreSQL."); + if (database.searchParams.get("sslmode") !== "require" || database.searchParams.get("sslaccept") !== "strict") { + throw new Error("ROOTLINE_API_DATABASE_URL must verify PostgreSQL TLS with sslmode=require&sslaccept=strict."); + } + if (new URL(process.env.ROOTLINE_JWT_ISSUER).protocol !== "https:") throw new Error("ROOTLINE_JWT_ISSUER must use HTTPS."); + const jwks = JSON.parse(Buffer.from(process.env.ROOTLINE_JWT_JWKS_B64, "base64").toString("utf8")); + if (!Array.isArray(jwks.keys) || !jwks.keys.some((key) => key?.kty === "RSA")) { + throw new Error("ROOTLINE_JWT_JWKS_B64 must decode to a JWKS with an RSA public key."); + } + NODE + node -e "const p=require('./apps/api/package.json'); if(p.version !== '2.0.0') throw new Error('apps/api/package.json must be version 2.0.0')" + + image: + needs: preflight + runs-on: ubuntu-24.04 + environment: api-production + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.immutable.outputs.image }} + build_id: ${{ steps.identity.outputs.build_id }} + stable_image: ${{ steps.image.outputs.stable_image }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: identity + run: echo "build_id=rootline-2.0.0-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - id: image + run: | + repository="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/rootline-api" + echo "image=${repository}:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + echo "stable_image=${repository}:2.0.0" >> "$GITHUB_OUTPUT" + - name: Refuse to overwrite stable image tag + env: + IMAGE: ${{ steps.image.outputs.stable_image }} + run: | + set +e + inspection=$(docker buildx imagetools inspect "$IMAGE" 2>&1) + inspection_status=$? + set -e + if [ "$inspection_status" -eq 0 ]; then + echo "::error::Stable API image ${IMAGE} already exists. Published 2.0.0 bytes are immutable; release a new version." + exit 1 + fi + case "$inspection" in + *"not found"*|*"manifest unknown"*) ;; + *) echo "::error::Could not prove stable API tag ${IMAGE} is unused; refusing to push."; printf '%s\n' "$inspection"; exit 1 ;; + esac + - id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: apps/api/Dockerfile + push: true + tags: ${{ steps.image.outputs.image }} + build-args: ROOTLINE_BUILD_ID=${{ steps.identity.outputs.build_id }} + - id: immutable + name: Resolve immutable image reference + env: + IMAGE: ${{ steps.image.outputs.image }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + repository=${IMAGE%:*} + echo "image=${repository}@${DIGEST}" >> "$GITHUB_OUTPUT" + + migrate: + needs: [preflight, image] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288ca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Apply checked-in production migrations + env: + DATABASE_URL: ${{ secrets.ROOTLINE_API_DATABASE_URL }} + run: pnpm --filter @rootline/api exec prisma migrate deploy + + deploy: + needs: [preflight, image, migrate] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - name: Request deployment of the immutable image + env: + IMAGE: ${{ needs.image.outputs.image }} + ROOTLINE_BUILD_ID: ${{ needs.image.outputs.build_id }} + DEPLOY_URL: ${{ secrets.ROOTLINE_API_DEPLOY_WEBHOOK_URL }} + DEPLOY_TOKEN: ${{ secrets.ROOTLINE_API_DEPLOY_TOKEN }} + run: | + curl --fail-with-body --silent --show-error --retry 3 -X POST \ + -H "Authorization: Bearer ${DEPLOY_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{\"image\":\"${IMAGE}\",\"buildId\":\"${ROOTLINE_BUILD_ID}\"}" \ + "${DEPLOY_URL}" + + health: + needs: [preflight, image, deploy] + runs-on: ubuntu-24.04 + environment: api-production + steps: + - name: Require deployed API health + env: + ROOTLINE_API_BASE_URL: ${{ secrets.ROOTLINE_API_BASE_URL }} + EXPECTED_BUILD_ID: ${{ needs.image.outputs.build_id }} + run: | + set -eu + for attempt in $(seq 1 30); do + echo "Health check attempt ${attempt}/30" + body=$(curl --fail --silent --show-error --max-time 10 "${ROOTLINE_API_BASE_URL%/}/healthz" || true) + printf '%s' "$body" | jq -e '.status == "ok" and .buildId == env.EXPECTED_BUILD_ID' >/dev/null && exit 0 + sleep 10 + done + echo "::error::Stable API release blocked: /healthz did not report the requested build identity ${EXPECTED_BUILD_ID}." + exit 1 + + promote: + needs: [image, health] + runs-on: ubuntu-24.04 + environment: api-production + permissions: + contents: read + packages: write + steps: + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Recheck stable tag before promotion + env: + STABLE_IMAGE: ${{ needs.image.outputs.stable_image }} + run: | + set +e + inspection=$(docker buildx imagetools inspect "$STABLE_IMAGE" 2>&1) + inspection_status=$? + set -e + if [ "$inspection_status" -eq 0 ]; then + echo "::error::Stable API image ${STABLE_IMAGE} already exists. Refusing to overwrite it." + exit 1 + fi + case "$inspection" in + *"not found"*|*"manifest unknown"*) ;; + *) echo "::error::Could not prove stable API tag ${STABLE_IMAGE} is unused; refusing promotion."; printf '%s\n' "$inspection"; exit 1 ;; + esac + - name: Promote the verified digest to the stable tag + env: + SOURCE_IMAGE: ${{ needs.image.outputs.image }} + STABLE_IMAGE: ${{ needs.image.outputs.stable_image }} + run: docker buildx imagetools create --tag "$STABLE_IMAGE" "$SOURCE_IMAGE" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..d2ae29d --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,231 @@ +name: Release Rootline Desktop 2.0.0 + +"on": + push: + tags: ["v2.0.0"] + workflow_dispatch: + inputs: + confirmation: + description: Type release-desktop-v2.0.0 while running from tag v2.0.0 + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-desktop-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Require stable tag, release confirmation, signing credentials, and hosted profile endpoints + env: + CONFIRMATION: ${{ inputs.confirmation }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable desktop release is restricted to the existing tag refs/tags/v2.0.0." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${CONFIRMATION}" != "release-desktop-v2.0.0" ]; then + echo "::error::Stable desktop release blocked. Re-run from tag v2.0.0 with confirmation=release-desktop-v2.0.0." + exit 1 + fi + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID APPLE_KEYCHAIN_PASSWORD WINDOWS_CERTIFICATE WINDOWS_CERTIFICATE_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD TAURI_UPDATER_PUBLIC_KEY VITE_AUTHENTIK_ISSUER VITE_AUTHENTIK_CLIENT_ID VITE_ROOTLINE_SYNC_API; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Stable signed desktop release blocked. Configure repository environment secrets/variables:$missing" + exit 1 + fi + case "${VITE_AUTHENTIK_ISSUER}" in https://*) ;; *) echo "::error::VITE_AUTHENTIK_ISSUER must use HTTPS."; exit 1 ;; esac + case "${VITE_ROOTLINE_SYNC_API}" in https://*) ;; *) echo "::error::VITE_ROOTLINE_SYNC_API must use HTTPS."; exit 1 ;; esac + node -e "const p=require('./apps/desktop/package.json'); if(p.version !== '2.0.0') throw new Error('apps/desktop/package.json must be version 2.0.0')" + + macos-universal: + needs: preflight + runs-on: macos-15 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Developer ID certificate into an isolated keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/rootline.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security default-keychain -s "$RUNNER_TEMP/rootline.keychain-db" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security set-keychain-settings -t 3600 -u "$RUNNER_TEMP/rootline.keychain-db" + security import "$RUNNER_TEMP/rootline.p12" -k "$RUNNER_TEMP/rootline.keychain-db" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security find-identity -v -p codesigning "$RUNNER_TEMP/rootline.keychain-db" | grep -F "$APPLE_SIGNING_IDENTITY" + - name: Build, sign, notarize, and staple macOS Universal artifacts + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + node -e 'require("node:fs").writeFileSync(process.env.RUNNER_TEMP + "/updater-config.json", JSON.stringify({plugins:{updater:{pubkey:process.env.TAURI_UPDATER_PUBLIC_KEY}}}))' + pnpm --filter @rootline/desktop tauri build --target universal-apple-darwin --bundles app,dmg --config "$RUNNER_TEMP/updater-config.json" + - name: Verify and stage signed updater and installer + run: | + set -eu + bundle=apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle + app=$(find "$bundle/macos" -maxdepth 1 -type d -name '*.app' -print -quit) + dmg=$(find "$bundle/dmg" -type f -name '*.dmg' -print -quit) + updater=$(find "$bundle" -type f -name '*.app.tar.gz' -print -quit) + [ -n "$app" ] && [ -n "$dmg" ] && [ -n "$updater" ] && [ -s "$updater.sig" ] + codesign --verify --deep --strict --verbose=2 "$app" + xcrun stapler validate "$dmg" + mkdir release-assets + cp "$dmg" release-assets/rootline-2.0.0-darwin-universal.dmg + cp "$updater" release-assets/rootline-2.0.0-darwin-universal.app.tar.gz + cp "$updater.sig" release-assets/rootline-2.0.0-darwin-universal.app.tar.gz.sig + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-darwin-universal + path: release-assets + if-no-files-found: error + + windows: + needs: preflight + environment: desktop-production + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - platform: windows-aarch64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Windows Authenticode certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP "rootline.pfx" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.Thumbprint) { throw "Imported Windows certificate has no thumbprint." } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + - name: Build and Authenticode-sign Windows installer and updater + shell: pwsh + env: + TAURI_TARGET: ${{ matrix.target }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + $config = @{ bundle = @{ windows = @{ certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT; digestAlgorithm = "sha256"; timestampUrl = "http://timestamp.digicert.com" } }; plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBLIC_KEY } } } | ConvertTo-Json -Compress -Depth 5 + pnpm --filter @rootline/desktop tauri build --target $env:TAURI_TARGET --bundles nsis --config $config + if ($LASTEXITCODE -ne 0) { throw "Tauri Windows release build failed." } + - name: Verify and stage signed updater and installer + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + TAURI_TARGET: ${{ matrix.target }} + run: | + $bundle = "apps/desktop/src-tauri/target/$env:TAURI_TARGET/release/bundle" + $installer = Get-ChildItem -Path $bundle -Recurse -File -Filter "*.exe" | Select-Object -First 1 + if (-not $installer -or -not (Test-Path "$($installer.FullName).sig")) { throw "Signed NSIS installer/updater artifacts are incomplete." } + $authenticode = Get-AuthenticodeSignature $installer.FullName + if ($authenticode.Status -ne "Valid") { throw "Installer Authenticode status is $($authenticode.Status)." } + New-Item -ItemType Directory -Path release-assets | Out-Null + Copy-Item $installer.FullName "release-assets/rootline-2.0.0-$env:PLATFORM-setup.exe" + Copy-Item "$($installer.FullName).sig" "release-assets/rootline-2.0.0-$env:PLATFORM-setup.exe.sig" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-${{ matrix.platform }} + path: release-assets + if-no-files-found: error + + publish-release: + needs: [preflight, macos-universal, windows] + runs-on: ubuntu-24.04 + environment: desktop-production + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: rootline-* + path: release-assets + merge-multiple: true + - name: Build updater latest.json from signed artifacts + env: + RELEASE_PUBLISHED_AT: ${{ github.event.head_commit.timestamp }} + run: node scripts/create-updater-manifest.mjs release-assets "https://github.com/${GITHUB_REPOSITORY}/releases/download/v2.0.0/" release-assets/latest.json + - name: Publish signed installers and updater manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + printf '%s\n' 'Rootline by baole.space 2.0.0 stable desktop release.' > release-notes.md + gh release create v2.0.0 release-assets/* --verify-tag --title "Rootline by baole.space 2.0.0" --notes-file release-notes.md diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..fe3650c --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,103 @@ +name: Release npm 2.0.0 + +"on": + push: + tags: ["v2.0.0"] + workflow_dispatch: + inputs: + confirmation: + description: Type release-v2.0.0 to publish the stable package + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-npm-2.0.0 + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: npm-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Require exact stable ref, version, and npm credential + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + CONFIRMATION: ${{ inputs.confirmation }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/tags/v2.0.0" ]; then + echo "::error::Stable npm release is restricted to refs/tags/v2.0.0." + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${CONFIRMATION}" != "release-v2.0.0" ]; then + echo "::error::Stable npm release blocked. Re-run with confirmation=release-v2.0.0." + exit 1 + fi + if [ -z "${NPM_TOKEN}" ]; then + echo "::error::Stable npm release blocked. Configure the NPM_TOKEN repository secret with publish access to folder-structure-sync." + exit 1 + fi + node -e "const p=require('./packages/cli/package.json'); if(p.version !== '2.0.0') throw new Error('packages/cli/package.json must be version 2.0.0')" + + pack: + needs: preflight + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + checksum: ${{ steps.checksum.outputs.sha256 }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: pnpm lint && pnpm typecheck && pnpm test:unit + - run: pnpm --filter folder-structure-sync test:pack + - run: mkdir release && pnpm --filter folder-structure-sync pack --pack-destination release + - id: checksum + name: Record the exact publish tarball checksum + run: | + checksum=$(sha256sum release/folder-structure-sync-2.0.0.tgz | cut -d ' ' -f 1) + echo "${checksum} folder-structure-sync-2.0.0.tgz" > release/SHA256SUMS + echo "sha256=${checksum}" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-npm-2.0.0 + path: release + if-no-files-found: error + + publish: + needs: [preflight, pack] + runs-on: ubuntu-24.04 + environment: npm-production + permissions: + contents: none + id-token: write + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: rootline-npm-2.0.0 + path: release + - name: Verify unprivileged pack output + env: + EXPECTED_SHA256: ${{ needs.pack.outputs.checksum }} + run: | + set -eu + printf '%s %s\n' "$EXPECTED_SHA256" release/folder-structure-sync-2.0.0.tgz | sha256sum --check --strict + node -e "const p=require('child_process').execFileSync('tar',['-xOzf','release/folder-structure-sync-2.0.0.tgz','package/package.json']); const m=JSON.parse(p); if(m.name !== 'folder-structure-sync' || m.version !== '2.0.0') throw new Error('Unexpected packed npm identity')" + - name: Publish immutable npm package with provenance + run: npm publish release/folder-structure-sync-2.0.0.tgz --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9237ca9..86c9620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - Unreleased ### Added -- Initial release preparation -- GitHub repository setup -- Comprehensive documentation +- Rootline Desktop for macOS Universal and Windows x64/ARM64. +- A shared, deterministic additive-sync core and the `folder-sync` Node.js 20 CLI. +- Optional Authentik-hosted profile sync backed by PostgreSQL; local-only profiles remain the default. +- Fail-closed CI and protected npm, API, desktop signing, notarization, and updater workflows. + +### Changed + +- The v2 CLI entry point is `folder-sync`; the root `node index.js` program is retained only as `1.x` migration evidence. +- Stable distribution remains blocked until the documented production environments, credentials, signing identities, updater key, and reviewed `v2.0.0` tag exist and pass. + +## [1.1.0] - 2025-08-10 + +### Preserved + +- Published npm behavior recovered byte-for-byte as migration evidence. See [the 1.1.0 recovery record](docs/baseline/npm-1.1.0-recovery.md) for registry integrity and provenance details. ## [1.0.0] - 2025-08-09 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42adc6d..7a7467a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,70 +1,55 @@ -# Contributing to Folder Structure Sync +# Contributing to Rootline -Thank you for your interest in contributing! 🎉 +## Local setup -## 🚀 Quick Start +Requirements: Node.js 20+, pnpm 10.33.0, stable Rust with `rustfmt` and `clippy`, Docker with Compose, and the native Tauri 2 prerequisites for your operating system. -1. Fork the repository -2. Clone your fork: `git clone https://github.com/unique01082/folder-structure-sync.git` -3. Install dependencies: `npm install` -4. Create a feature branch: `git checkout -b feature/amazing-feature` -5. Make your changes -6. Test your changes: `node index.js test-source test-target --dry-run` -7. Commit: `git commit -m 'Add amazing feature'` -8. Push: `git push origin feature/amazing-feature` -9. Create a Pull Request +```bash +git clone https://github.com/unique01082/folder-structure-sync.git +cd folder-structure-sync +corepack enable +pnpm install --frozen-lockfile +``` -## 📋 Development Guidelines +## Development commands -### Code Style +```bash +pnpm lint +pnpm typecheck +pnpm test:unit +pnpm build +pnpm --filter folder-structure-sync test:pack +pnpm --filter @rootline/api test:e2e:postgres +cargo fmt --check --manifest-path apps/desktop/src-tauri/Cargo.toml +cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets --all-features -- -D warnings +cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml +``` -- Use clear, descriptive variable names -- Add comments for complex logic -- Follow existing patterns in the codebase -- Use meaningful commit messages +The API integration command provisions disposable PostgreSQL 16, applies the real Prisma migrations, runs the full NestJS/PostgreSQL suite (including the desktop SQLite seam), and removes the database. The npm smoke test packs and installs only the public CLI tarball; workspace packages must not hide missing registry dependencies. -### Testing +For desktop development, run `pnpm --filter @rootline/desktop tauri dev`. Local source builds are developer workflows, not user installation instructions. -- Test with various folder structures -- Always test with `--dry-run` first -- Test error conditions (permissions, missing folders, etc.) -- Test on different operating systems if possible +## Change workflow -### Pull Request Process +1. Write a focused failing test for every behavior change and verify the expected failure. +2. Implement the smallest change that passes it. +3. Run the focused test, then the relevant package suite and repository gates. +4. Update the owning document once and link to it instead of duplicating content. +5. Preserve the additive-only synchronization, local-data, privacy, and signing boundaries. -1. Update documentation if needed -2. Add examples for new features -3. Ensure backwards compatibility -4. Update CHANGELOG.md if applicable +Do not publish npm packages, push images, create releases, deploy the API, or sign artifacts from a pull request. Release workflows are intentionally isolated behind production environments and exact version confirmations. -## 🐛 Reporting Issues +## Pull request checklist -When reporting bugs, please include: +- [ ] Red/green test evidence is included. +- [ ] TypeScript and Rust gates relevant to the change pass. +- [ ] npm tarball or Tauri target smoke ran when distribution changed. +- [ ] Real PostgreSQL tests ran when API/database behavior changed. +- [ ] No tokens, absolute paths, generated credentials, or production data appear in logs/fixtures. +- [ ] Documentation links resolve and every new doc has a Related section. -- Operating system and version -- Node.js version -- Command that caused the issue -- Expected vs actual behavior -- Any error messages +## Related -## 💡 Feature Requests - -We welcome feature requests! Please: - -- Check existing issues first -- Clearly describe the use case -- Provide examples of how it would work -- Consider backwards compatibility - -## 📝 Documentation - -Help improve our documentation by: - -- Fixing typos and unclear explanations -- Adding more examples -- Improving code comments -- Updating README.md - -## 🙏 Thank You - -Every contribution helps make this project better for everyone! +- [Architecture](docs/architecture.md) - Package and trust boundaries. +- [Operations](docs/operations.md) - Local and hosted service validation. +- [Release process](docs/release.md) - Production-only gates. diff --git a/EXAMPLES.md b/EXAMPLES.md index 3ec83c3..92a27e5 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,222 +1,43 @@ -# Folder Structure Sync - Usage Examples +# Rootline CLI examples -## Example 1: Basic Interactive Sync +These examples cover the `2.0.0` `folder-sync` command. The preserved root `node index.js` program is `1.x` migration evidence and is not the v2 entry point. -```bash -node index.js ./my-project-source ./my-project-target -``` - -This will: - -1. Scan both directories -2. Show missing folders with colored, indented display -3. Let you select which folders to create using checkboxes -4. Auto-include parent folders for dependencies -5. Show confirmation before creating -6. Create folders with progress bar - -## Example 2: Dry Run (Preview Only) +## Preview safely ```bash -node index.js ./source ./target --dry-run +folder-sync ./source ./target --dry-run ``` -Perfect for: - -- Checking what would be created without making changes -- Planning your sync strategy -- Verifying exclusion rules work correctly +Dry-run mode scans both roots and reports missing directories without creating them. A missing target remains untouched. -## Example 3: Auto Mode (No Prompts) +## Apply every missing directory ```bash -node index.js ./source ./target --auto +folder-sync ./source ./target --auto ``` -Great for: +Without `--auto`, Rootline asks for confirmation in an interactive terminal. Rootline creates directories only; it does not copy, move, rename, or delete files. -- Automated scripts -- CI/CD pipelines -- When you trust the source structure completely - -## Example 4: Verbose Output +## Automation with JSON ```bash -node index.js ./source ./target --verbose +folder-sync ./source ./target --auto --json ``` -Shows: - -- Detailed folder creation messages -- Full paths being created -- Any warnings or errors encountered +`--json` emits one JSON document and never prompts. Combine it with `--auto` to apply a plan in automation. Exit code `0` means success or a deliberate no-op, `1` means an operational failure, and `2` means invalid usage. -## Example 5: Combined Options +## Explicit configuration ```bash -node index.js ./source ./target --dry-run --verbose --auto -``` - -Ultimate preview mode: - -- Shows everything that would happen -- No actual changes made -- Detailed output - -## Interactive Selection Examples - -### Checkbox Interface - -``` -Missing folders found in target: -[1] ✓ src/ -[2] ✓ src/components/ -[3] ✗ src/utils/ -[4] ✓ docs/ -[5] ✗ docs/images/ -[6] ✓ tests/ - -Use arrow keys to navigate, space to toggle, enter to confirm -``` - -### Manual Number Input - -``` -Or enter folder numbers separated by commas (e.g., 1,3,5): 2,4,6 -``` - -## Configuration Examples - -### Default sync-config.json - -```json -{ - "defaultExclusions": [ - ".git", - "node_modules", - ".DS_Store", - "Thumbs.db", - ".vscode", - ".idea", - "*.tmp", - "*.log", - "dist", - "build" - ], - "customExclusions": [] -} +folder-sync ./source ./target --dry-run --config ./rootline.config.json ``` -### Custom Exclusions - ```json { - "defaultExclusions": [...], - "customExclusions": [ - "my-temp-folder", - "*.backup", - "old-*", - ".custom-cache" - ] + "defaultExclusions": [".git", "node_modules", "dist", "build"], + "customExclusions": ["private-cache"], + "targetCaseSensitive": true } ``` -## Common Use Cases - -### 1. Project Template Sync - -Keep your project templates in sync across different environments: - -```bash -node index.js ./project-template ./new-project --auto -``` - -### 2. Development Environment Setup - -Replicate folder structure for new team members: - -```bash -node index.js ./team-project-structure ./my-local-copy -``` - -### 3. Backup Folder Structure - -Create folder structure in backup location: - -```bash -node index.js ./production ./backup-structure --dry-run -# Review, then: -node index.js ./production ./backup-structure --auto -``` - -### 4. Migration Planning - -Preview folder structure changes before migration: - -```bash -node index.js ./old-structure ./new-structure --dry-run --verbose -``` - -## Error Handling Examples - -### Missing Source Directory - -``` -❌ Error: Source directory does not exist: ./non-existent-path -``` - -### Missing Target Directory - -``` -⚠️ Target directory does not exist: ./new-target -? Would you like to create the target directory? (Y/n) -``` - -### Permission Errors - -``` -❌ Error creating C:\restricted\folder: EACCES: permission denied -📊 Summary: 4 created, 1 errors -``` - -## Advanced Tips - -### 1. Test Before Production - -Always use `--dry-run` first: - -```bash -# Test -node index.js ./source ./target --dry-run - -# Execute -node index.js ./source ./target --auto -``` - -### 2. Selective Sync - -Use interactive mode to sync only specific parts: - -```bash -node index.js ./large-project ./partial-copy -# Select only the folders you need -``` - -### 3. Automation Integration - -For scripts and automation: - -```bash -# Silent, automatic execution -node index.js "$SOURCE_DIR" "$TARGET_DIR" --auto > sync.log 2>&1 -``` - -### 4. Configuration Management - -Keep different config files for different scenarios: - -```bash -# Copy appropriate config before running -cp sync-config-production.json sync-config.json -node index.js ./source ./target --auto -``` +Configuration precedence and validation are documented in [Configuration](docs/configuration.md). The safe upgrade sequence from the published `1.1.0` behavior is documented in [Migration from 1.x to 2.0.0](docs/migration-v1-to-v2.md). diff --git a/PUBLISHING.md b/PUBLISHING.md index 88c3fe1..910dbdc 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,326 +1,11 @@ -# 📦 Publishing Guide +# Publishing Rootline -This guide covers the complete process for publishing new versions of `folder-structure-sync` to npm. +Rootline `2.0.0` is published only through the fail-closed GitHub Actions workflows. Do not run `npm publish`, push the API image, upload installers, or synthesize `latest.json` manually. -## 🚀 Quick Release Commands +See [the release runbook](docs/release.md) for the exact preflight, credential, migration, signing, notarization, updater, verification, and rollback gates. -```bash -# For bug fixes (1.0.0 → 1.0.1) -npm run release:patch +## Related -# For new features (1.0.0 → 1.1.0) -npm run release:minor - -# For breaking changes (1.0.0 → 2.0.0) -npm run release:major - -# Full safety check before publishing -npm run publish:check - -# Safe publish with all checks -npm run publish:safe -``` - -## 📋 Step-by-Step Process - -### 1. Pre-Release Preparation - -```bash -# Run comprehensive checks -npm run precheck - -# Test package functionality -npm run test-package -``` - -**What the pre-check validates:** - -- ✅ All required files exist -- ✅ package.json has all required fields -- ✅ Git working directory is clean -- ✅ Logged in to npm -- ✅ Tests pass (if any) -- ⚠️ Checks for TODO/FIXME comments - -### 2. Choose Release Type - -| Type | When to Use | Version Change | Example | -| --------- | ---------------------------------- | -------------- | ------------------------ | -| **patch** | Bug fixes, minor improvements | 1.0.0 → 1.0.1 | Fix CLI argument parsing | -| **minor** | New features (backward compatible) | 1.0.0 → 1.1.0 | Add new command option | -| **major** | Breaking changes | 1.0.0 → 2.0.0 | Change CLI interface | - -### 3. Execute Release - -```bash -# Example: releasing a minor version -npm run release:minor -``` - -**What the release script does:** - -1. 🔍 Checks current branch is master -2. 📥 Pulls latest changes -3. 🧪 Runs tests (if available) -4. 📈 Updates version in package.json -5. 📝 Updates CHANGELOG.md with new version -6. 📦 Stages and commits changes -7. 🏷️ Creates git tag -8. 📤 Pushes changes and tags -9. 📋 Shows instructions for npm publish - -### 4. Review and Publish - -After running the release script: - -1. **Review the changes:** - - ```bash - git log --oneline -5 - git show HEAD - ``` - -2. **Publish to npm:** - - ```bash - npm publish - ``` - -3. **Create GitHub Release:** - - Go to: https://github.com/unique01082/folder-structure-sync/releases/new - - Select the new tag - - Add release notes from CHANGELOG.md - - Publish release - -## 🔧 Manual Process (if needed) - -If you prefer manual control: - -### 1. Version Update - -```bash -# Update version manually -npm version patch # or minor/major -``` - -### 2. Update CHANGELOG.md - -Add new section with current date: - -```markdown -## [1.0.1] - 2025-08-09 - -### Fixed - -- Bug fixes and improvements - -### Changed - -- Minor updates and optimizations -``` - -### 3. Commit and Tag - -```bash -git add . -git commit -m "chore: release v1.0.1" -git tag v1.0.1 -git push origin master --tags -``` - -### 4. Publish - -```bash -npm publish -``` - -## 🧪 Testing Before Release - -### Local Testing - -```bash -# Test package creation and functionality -npm run test-package -``` - -### Test Installation - -```bash -# Create package and test locally -npm pack -npm install -g folder-structure-sync-1.0.1.tgz - -# Test commands -folder-sync --help -folder-sync ./test-source ./test-target --dry-run - -# Cleanup -npm uninstall -g folder-structure-sync -rm folder-structure-sync-1.0.1.tgz -``` - -## 📊 Post-Release Tasks - -### 1. Verify Publication - -```bash -# Check package on npm -npm view folder-structure-sync - -# Test installation -npx folder-structure-sync@latest --help -``` - -### 2. Update Documentation - -- [ ] Update README.md if needed -- [ ] Update examples in EXAMPLES.md -- [ ] Check all links work correctly - -### 3. Monitor and Promote - -- [ ] Check npm download stats -- [ ] Share on social media -- [ ] Update relevant communities - -## 🚨 Troubleshooting - -### Common Issues - -**Git not clean:** - -```bash -git status -git stash # if you want to save changes -# or -git add . && git commit -m "WIP: save changes" -``` - -**Not logged in to npm:** - -```bash -npm login -npm whoami # verify -``` - -**Version already exists:** - -```bash -# If you need to republish same version (not recommended) -npm publish --force - -# Better: increment version -npm version patch -npm publish -``` - -**Permission denied:** - -```bash -# Check if you're a maintainer -npm owner ls folder-structure-sync - -# Or contact current maintainer -``` - -### Recovery from Failed Release - -If release script fails midway: - -```bash -# Reset version if needed -git reset --hard HEAD~1 -git tag -d v1.0.1 # if tag was created - -# Or continue from where it failed -git push origin master -git push origin --tags -npm publish -``` - -## 📈 Version Strategy - -### Semantic Versioning (SemVer) - -- **MAJOR**: Breaking changes (2.0.0) - - - Change CLI interface - - Remove features - - Change file formats - -- **MINOR**: New features (1.1.0) - - - Add new options - - Add new commands - - Enhance existing features - -- **PATCH**: Bug fixes (1.0.1) - - Fix bugs - - Update dependencies - - Improve performance - -### Pre-release Versions - -For testing major changes: - -```bash -npm version 2.0.0-beta.1 -npm publish --tag beta - -# Users can test with: -npm install folder-structure-sync@beta -``` - -## 🎯 Checklist Template - -Before each release: - -- [ ] All features implemented and tested -- [ ] Documentation updated -- [ ] CHANGELOG.md entries added -- [ ] No TODO/FIXME in critical code -- [ ] Git working directory clean -- [ ] Logged in to npm -- [ ] Pre-publish checks pass -- [ ] Package tests pass - -After release: - -- [ ] npm package verified -- [ ] GitHub release created -- [ ] Documentation links updated -- [ ] Social media announcement -- [ ] Monitor for issues - -## 📝 Release Notes Template - -```markdown -## 🎉 folder-structure-sync v1.1.0 - -### ✨ New Features - -- Added support for custom configuration profiles -- Improved interactive selection with keyboard shortcuts - -### 🐛 Bug Fixes - -- Fixed issue with Windows path handling -- Resolved memory leak with large directory structures - -### 📖 Documentation - -- Updated examples with new features -- Added troubleshooting guide - -### 📦 Installation - -\`\`\`bash -npm install -g folder-structure-sync@latest -\`\`\` - -### 🔗 Links - -- [Full Changelog](https://github.com/unique01082/folder-structure-sync/blob/master/CHANGELOG.md) -- [Documentation](https://github.com/unique01082/folder-structure-sync#readme) -- [Issues](https://github.com/unique01082/folder-structure-sync/issues) -``` +- [Release runbook](docs/release.md) - Single source of truth for stable releases. +- [Operations](docs/operations.md) - API deployment and health checks. +- [Security policy](SECURITY.md) - Supply-chain requirements. diff --git a/QUICK-REFERENCE.md b/QUICK-REFERENCE.md index 25cb1ed..a08d10e 100644 --- a/QUICK-REFERENCE.md +++ b/QUICK-REFERENCE.md @@ -1,80 +1,19 @@ -# 🚀 Quick Reference - Publishing New Versions +# Rootline release quick reference -## ⚡ One-Command Publishing +Stable Rootline releases are workflow-only and fail closed. Do not run `npm publish`, create a GitHub release, deploy the API, or sign installers from a local checkout. -```bash -# Bug fixes (1.0.0 → 1.0.1) -npm run release:patch - -# New features (1.0.0 → 1.1.0) -npm run release:minor - -# Breaking changes (1.0.0 → 2.0.0) -npm run release:major -``` - -Then: `npm publish` - -## 🔍 Pre-Flight Checks - -```bash -# Run all checks -npm run precheck - -# Test package functionality -npm run test-package - -# Both checks + publish -npm run publish:safe -``` - -## 📊 Post-Release Tools - -```bash -# Package stats -npm run workflow:stats - -# Social media posts -npm run workflow:social - -# Promotion checklist -npm run workflow:promotion - -# Command reference -npm run workflow:commands -``` - -## 🎯 Release Workflow - -1. **Prepare** → `npm run precheck` -2. **Release** → `npm run release:minor` -3. **Publish** → `npm publish` -4. **Promote** → `npm run workflow:social` +Use [the release runbook](docs/release.md) for the exact npm, API, and desktop gates. The runbook identifies the protected GitHub environments, required production configuration, validation sequence, and external credentials that currently block a real `2.0.0` release. -## 🆘 Emergency Commands +Local validation is safe and does not publish: ```bash -# Check what will be published -npm pack --dry-run - -# Verify npm login -npm whoami - -# Check package on npm -npm view folder-structure-sync - -# Test installation -npx folder-structure-sync@latest --help +pnpm install --frozen-lockfile +pnpm lint +pnpm typecheck +pnpm test +pnpm build +pnpm test:release +pnpm validate:workflows ``` -## 📁 Files Created - -- `scripts/release.js` - Automated release process -- `scripts/pre-publish-check.js` - Pre-flight validation -- `scripts/test-package.js` - Package functionality testing -- `scripts/workflow.js` - Workflow automation helpers -- `PUBLISHING.md` - Detailed publishing guide - -## 🎉 Ready to Ship! - -Your package is now equipped with professional release automation. Just run the commands and follow the prompts! +Rootline `2.0.0` must not be described as shipped until every protected workflow completes from the reviewed `v2.0.0` tag. diff --git a/README.md b/README.md index f0a6b4e..2a3e865 100644 --- a/README.md +++ b/README.md @@ -1,296 +1,60 @@ -# 📁 Folder Structure Sync +# Rootline by baole.space -[![npm version](https://img.shields.io/npm/v/folder-structure-sync.svg?style=flat-square)](https://www.npmjs.com/package/folder-structure-sync) -[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg?style=flat-square)](https://opensource.org/licenses/ISC) -[![Node.js Version](https://img.shields.io/badge/node-%3E%3D12.0.0-brightgreen.svg?style=flat-square)](https://nodejs.org/) +Rootline safely reproduces a directory structure from one location into another without copying files or deleting existing content. -> 🚀 **Interactive CLI tool for syncing folder structures with smart selection, dependency handling, and beautiful output** +> **Release status:** `2.0.0` is a release candidate. Stable npm and desktop downloads are available only after the fail-closed release workflows complete with real signing and deployment credentials. Check [GitHub Releases](https://github.com/unique01082/folder-structure-sync/releases) and [npm](https://www.npmjs.com/package/folder-structure-sync) before installing; this repository does not claim unpublished artifacts are live. -Perfect for project templates, development environments, team onboarding, and automated deployments. Sync only what you need with intelligent dependency resolution and comprehensive exclusion patterns. +## Quick start -image +1. **Choose:** use the desktop application on macOS Universal or Windows x64/ARM64, or use the `folder-sync` CLI with Node.js 20 or newer. +2. **Install:** after `2.0.0` appears on the official release pages, download a signed installer or run `npm install --global folder-structure-sync@2.0.0`. +3. **Authenticate (optional):** sign in only if you want saved profiles synchronized. Rootline works fully offline and never requires an account for local synchronization. +4. **Try safely:** preview before applying: `folder-sync ./source ./target --dry-run`. +## What Rootline does -## 📚 Table of Contents +| Capability | Desktop | CLI | +|---|---:|---:| +| Scan source and target directories | Yes | Yes | +| Preview and select missing directories | Yes | Yes | +| Create missing directories additively | Yes | Yes | +| Copy files or delete content | Never | Never | +| Save local profiles and run history | Yes | No | +| Optional hosted profile sync | Yes | No | -- [✨ Features](#-features) -- [🚀 Quick Start](#-quick-start) -- [📦 Installation](#-installation) -- [📖 Usage](#-usage) - - [🎮 Interactive Mode](#-interactive-mode) - - [🔧 Command Options](#-command-options) - - [💡 Common Use Cases](#-common-use-cases) -- [⚙️ Configuration](#️-configuration) -- [🎯 Smart Features](#-smart-features) - - [🎮 Interactive Selection](#-interactive-selection) - - [🧠 Dependency Resolution](#-dependency-resolution) - - [🎨 Beautiful Output](#-beautiful-output) -- [📸 Screenshots & Examples](#-screenshots--examples) - - [✅ Success Output](#-success-output) - - [📋 Dry Run Output](#-dry-run-output) - - [⚠️ Error Handling](#️-error-handling) -- [🛠️ Development](#️-development) -- [🤝 Contributing](#-contributing) -- [📋 Roadmap](#-roadmap) -- [❓ FAQ](#-faq) -- [🔧 Troubleshooting](#-troubleshooting) -- [📄 License](#-license) -- [🙏 Acknowledgments](#-acknowledgments) +Rootline rejects source/target overlap, skips symbolic links and Windows junctions, revalidates a plan immediately before applying it, and keeps filesystem data and run history local. Hosted sync contains complete saved profile documents—including absolute source and target paths—but never directory trees, files, file contents, or run history. Rootline has no usage telemetry. -## ✨ Features +## CLI -- 🎯 **Interactive Selection**: Choose folders with checkbox interface or comma-separated numbers -- 🧠 **Smart Dependencies**: Auto-includes parent folders when children are selected -- 🚫 **Smart Exclusions**: Configurable patterns with sensible defaults (`.git`, `node_modules`, etc.) -- 🎨 **Beautiful Output**: Colorful, hierarchical display with progress bars -- 📋 **Dry Run Mode**: Preview changes safely before execution -- ⚡ **Auto Mode**: Perfect for scripts and CI/CD pipelines -- 📊 **Detailed Reporting**: Comprehensive operation summaries -- 🔧 **Cross-Platform**: Works on Windows, macOS, and Linux - -## 🚀 Quick Start - -```bash -# Install globally from npm -npm install -g folder-structure-sync - -# Or install locally in your project -npm install folder-structure-sync - -# Run interactively -folder-sync ./source-folder ./target-folder - -# Preview changes (recommended first run) -folder-sync ./source-folder ./target-folder --dry-run - -# Auto-sync everything -folder-sync ./source-folder ./target-folder --auto -``` - -## 📦 Installation - -```bash -# Global installation (recommended) -npm install -g folder-structure-sync - -# Local installation -npm install folder-structure-sync - -# Or run directly with npx (no installation needed) -npx folder-structure-sync ./source ./target --dry-run -``` - -## 📖 Usage - -### 🎮 Interactive Mode - -The default mode provides a user-friendly selection interface: - -```bash -folder-sync ./source-project ./target-project -``` - -**Example interaction:** - -``` -📂 Found 5 missing folders in target: -[1] ✓ src/ -[2] ✓ src/components/ -[3] ✗ src/utils/ -[4] ✓ docs/ -[5] ✓ tests/ - -🎯 Select folders to create: - Use arrow keys to navigate, space to toggle, enter to confirm -``` - -### 🔧 Command Options - -- `-d, --dry-run`: Preview changes without executing -- `-v, --verbose`: Show detailed output -- `-a, --auto`: Auto-create all missing folders without prompting -- `-h, --help`: Show help information - -### 💡 Common Use Cases - -```bash -# 🔍 Preview changes (recommended first!) -folder-sync ./my-template ./new-project --dry-run - -# 🏗️ Project template setup -folder-sync ./project-template ./new-project --auto - -# 👥 Team environment replication -folder-sync ./team-structure ./my-local-copy - -# 📦 Selective sync with verbose output -folder-sync ./large-project ./partial-copy --verbose - -# 🤖 Automation/CI-CD pipeline -folder-sync "$SOURCE" "$TARGET" --auto -``` - -## ⚙️ Configuration - -The tool uses a `sync-config.json` file for exclusion patterns: - -```json -{ - "defaultExclusions": [ - ".git", - ".svn", - ".hg", // Version control - "node_modules", - ".npm", - ".yarn", // Package managers - ".DS_Store", - "Thumbs.db", // OS files - ".vscode", - ".idea", // IDE files - "*.tmp", - "*.log", - "*.cache", // Temporary files - "dist", - "build", - ".next" // Build outputs - ], - "customExclusions": [ - "my-custom-folder", // Add your patterns here - "*.backup" - ] -} -``` - -**💡 Pro tip**: Customize `customExclusions` for project-specific needs! - -## 🎯 Smart Features - -### 🎮 Interactive Selection - -**Two ways to select folders:** - -1. **Checkbox Interface**: Navigate with `↑↓`, toggle with `Space`, confirm with `Enter` -2. **Number Input**: Type comma-separated numbers like `1,3,5` or ranges `1-5` - -### 🧠 Dependency Resolution - -When you select `src/components/buttons/`, the tool automatically: - -- ✅ Includes parent folders: `src/` → `src/components/` → `src/components/buttons/` -- 📋 Shows you the complete dependency tree -- ⚡ Creates folders in the correct order - -### 🎨 Beautiful Output - -``` -🔍 Validating paths... -📁 Scanning directories... - -📂 Found 5 missing folders: - [1] src/ # Root level - cyan - [2] src/components/ # Level 1 - yellow - [3] src/components/ui/ # Level 2 - green - [4] docs/ # Root level - cyan - [5] tests/ # Root level - cyan - -🚀 Creating folders... -Progress |████████████████████| 100% | 5/5 folders - -🎉 Success: 5 created, 0 errors -``` - -## 📸 Screenshots & Examples - -### ✅ Success Output - -``` -🎉 Successfully processed 5 folders! -📊 Summary: 5 created, 0 errors -``` - -### 📋 Dry Run Output +```text +folder-sync [options] +--dry-run Preview without creating directories +--verbose Print scan details +--auto Select all missing directories without prompting +--config PATH Read an explicit JSON configuration +--json Emit one JSON document and never prompt ``` -📋 Dry run - folders that would be created: - 1. /target/src - 2. /target/src/components - 3. /target/docs - 4. /target/tests - -📋 This was a dry run - no actual changes were made. -``` - -### ⚠️ Error Handling - -``` -❌ Error creating /restricted/folder: EACCES: permission denied -📊 Summary: 4 created, 1 error -``` - -## 🛠️ Development - -```bash -# Clone and setup -git clone https://github.com/unique01082/folder-structure-sync.git -cd folder-structure-sync -npm install - -# Run tests (when available) -npm test - -# Test with sample data -node index.js ./test-source ./test-target --dry-run -``` - -## 🤝 Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. - -1. Fork the project -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## 📋 Roadmap - -- [x] 📦 NPM package publication -- [ ] 🧪 Comprehensive test suite -- [ ] 📊 File sync capabilities (not just folders) -- [ ] 🌐 Configuration presets for popular frameworks -- [ ] 🔍 Advanced filtering with regex patterns -- [ ] 📱 Interactive web interface -- [ ] ⚡ Performance optimizations for large directories - -## ❓ FAQ - -**Q: Does this tool copy files?** -A: No, it only creates folder structures. Files are not copied or modified. - -**Q: Is it safe to use in production?** -A: Yes, especially with `--dry-run` first. The tool only creates folders and includes comprehensive error handling. - -**Q: Can I exclude specific patterns?** -A: Absolutely! Use `sync-config.json` to customize exclusion patterns. - -**Q: Does it work with network drives?** -A: Yes, as long as you have appropriate permissions. - -## 🔧 Troubleshooting -**Permission Errors**: Run with elevated privileges or check folder permissions -**Large Directories**: Use `--verbose` to monitor progress -**Configuration Issues**: Check `sync-config.json` syntax with a JSON validator +`--json` is intended for automation. Exit code `0` means success or a deliberate no-op, `1` means a filesystem/configuration/apply failure, and `2` means invalid command usage. -## 📄 License +## Documentation -This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details. +- [Documentation hub](docs/README.md) +- [Configuration reference](docs/configuration.md) +- [Architecture and data boundaries](docs/architecture.md) +- [Migrate from npm 1.1.0](docs/migration-v1-to-v2.md) +- [Privacy](docs/privacy.md) +- [Operations](docs/operations.md) +- [Release process](docs/release.md) +- [Security policy](SECURITY.md) +- [Contributor workflow](CONTRIBUTING.md) -## 🙏 Acknowledgments +## License -- Built with ❤️ using [Commander.js](https://github.com/tj/commander.js/), [Chalk](https://github.com/chalk/chalk), [Inquirer.js](https://github.com/SBoudrias/Inquirer.js/), and [CLI Progress](https://github.com/npkgz/cli-progress) -- Inspired by the need for better development environment synchronization +Rootline remains available under the [ISC License](LICENSE). ---- +## Related -**⭐ Star this repo if it helped you!** | **🐛 [Report bugs](https://github.com/unique01082/folder-structure-sync/issues)** | **💡 [Request features](https://github.com/unique01082/folder-structure-sync/issues)** +- [Rootline documentation](docs/README.md) - Complete user, operator, and contributor navigation. +- [npm 1.1.0 recovery evidence](docs/baseline/npm-1.1.0-recovery.md) - Provenance of the preserved legacy behavior. diff --git a/SECURITY.md b/SECURITY.md index 2dfe8da..149b7d5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,44 +1,37 @@ -# Security Policy +# Rootline security policy -## Supported Versions +## Supported versions -We support the latest version of folder-structure-sync. Please ensure you're using the most recent version before reporting security issues. +| Version | Status | +|---|---| +| `2.0.x` | Supported after the stable `2.0.0` release | +| `1.1.x` | Security fixes only during the v2 migration window | +| Earlier | Unsupported | -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | +## Report a vulnerability -## Reporting a Vulnerability +Use a private GitHub security advisory for `unique01082/folder-structure-sync`. Do not include tokens, absolute filesystem paths, database credentials, signing keys, or personal data in a public issue. Include affected version/platform, impact, reproduction steps, and any known mitigations. -If you discover a security vulnerability, please report it by emailing [your-email@example.com] or creating a private security advisory on GitHub. +Release signing or hosted-service credentials are never accepted through issues or pull requests. Rotate any credential accidentally disclosed in logs or source before continuing a release. -**Please do NOT report security vulnerabilities through public GitHub issues.** +## Security boundaries -When reporting a vulnerability, please include: +- Local synchronization is one-way and additive: Rootline creates missing directories only. +- Source and target cannot be equal, ancestors, descendants, symbolic links, or Windows junction aliases. +- The plan is revalidated before mutation; cancellation stops between directory operations. +- Offline profiles, directory trees, file names/content, device state, and run history remain local. +- Optional hosted sync sends complete saved profiles, including absolute source and target paths, only after explicit sign-in and consent. +- OIDC uses Authorization Code with PKCE. Tokens and protocol state are held behind the native Stronghold/OS credential boundary, not localStorage or React state. +- API tenant identity comes only from the verified RS256 token subject and permission claim. +- Production logs must not contain bearer tokens, request bodies, profile values, or absolute paths. +- Rootline contains no usage telemetry. -- Description of the vulnerability -- Steps to reproduce the issue -- Potential impact -- Any suggested fixes +## Supply-chain and stable release controls -We will respond to security reports within 48 hours and provide regular updates on our progress. +Stable npm publication is restricted to `v2.0.0`, requires npm provenance, and installs the packed CLI in isolation before publishing. Stable API deployment requires an immutable image, checked-in PostgreSQL migrations, HTTPS deployment/health endpoints, and complete production secrets. Desktop release requires Apple signing and notarization, Windows Authenticode signing, and a non-empty Tauri updater signature for every platform. Missing inputs stop the release with an actionable error; signatures are never disabled as a fallback. -## Security Considerations +## Related -This tool: - -- Only creates directories (never modifies or deletes existing content) -- Respects filesystem permissions -- Does not execute any external commands -- Does not transmit data over the network -- Reads configuration only from local JSON files - -## Best Practices - -When using this tool: - -- Always use `--dry-run` first in production environments -- Review the list of folders to be created before confirming -- Ensure you have appropriate permissions for the target directory -- Use version control for your configuration files -- Regularly update to the latest version +- [Privacy](docs/privacy.md) - Data collection and hosted profile scope. +- [Architecture](docs/architecture.md) - Trust boundaries and ownership. +- [Release process](docs/release.md) - Fail-closed release gates. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..81f0657 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-bookworm-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0 AS base + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +FROM base AS build + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +WORKDIR /app + +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY apps/api/package.json apps/api/package.json +RUN pnpm install --frozen-lockfile --filter @rootline/api... + +COPY apps/api apps/api +RUN pnpm --filter @rootline/api prisma:generate && pnpm --filter @rootline/api build + +FROM base AS runtime + +ARG ROOTLINE_BUILD_ID=development +ENV NODE_ENV=production +ENV PORT=3000 +ENV ROOTLINE_BUILD_ID=$ROOTLINE_BUILD_ID +WORKDIR /app + +RUN groupadd --system rootline && useradd --system --gid rootline rootline +COPY --from=build --chown=rootline:rootline /app/node_modules /app/node_modules +COPY --from=build --chown=rootline:rootline /app/apps/api/node_modules /app/apps/api/node_modules +COPY --from=build --chown=rootline:rootline /app/apps/api/dist /app/apps/api/dist +COPY --from=build --chown=rootline:rootline /app/apps/api/package.json /app/apps/api/package.json +COPY --from=build --chown=rootline:rootline /app/apps/api/prisma /app/apps/api/prisma + +USER rootline +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] +CMD ["node", "apps/api/dist/main.js"] diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts index 8449c43..088439e 100644 --- a/apps/api/src/health.controller.ts +++ b/apps/api/src/health.controller.ts @@ -9,5 +9,5 @@ export class HealthController { @Get("healthz") @ApiOperation({ summary: "Readiness/liveness probe" }) @ApiResponse({ status: 200 }) - health() { return { status: "ok" }; } + health() { return { status: "ok", buildId: process.env.ROOTLINE_BUILD_ID || "development" }; } } diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index 956416f..7b9152c 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -84,7 +84,7 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { } test("keeps health public and rejects wrong issuer, audience, or permission", async () => { - await request(fixture.server).get("/healthz").expect(200, { status: "ok" }); + await request(fixture.server).get("/healthz").expect(200, { status: "ok", buildId: "development" }); const body = { deviceId: "device-a", epoch: EPOCH, mutations: [] }; await request(fixture.server).post("/v1/sync").send(body).expect(401); await request(fixture.server) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0ac59e4..75ec2ab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "@tauri-apps/plugin-deep-link": "^2.4.3", "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-stronghold": "^2.3.0", + "@tauri-apps/plugin-updater": "^2.10.0", "oidc-client-ts": "^3.3.0", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 46d042a..2dcc62f 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -117,6 +117,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1073,6 +1082,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2543,6 +2563,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2755,7 +2805,7 @@ dependencies = [ "tar", "ureq", "vcpkg", - "zip", + "zip 8.6.0", ] [[package]] @@ -3165,6 +3215,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3180,6 +3231,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3260,6 +3323,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3286,6 +3355,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "pango" version = "0.18.3" @@ -3948,15 +4031,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4039,6 +4127,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-stronghold", + "tauri-plugin-updater", "tempfile", "thiserror 2.0.20", "time", @@ -4136,6 +4225,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4146,6 +4247,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.14" @@ -4187,6 +4315,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4560,6 +4697,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -4841,7 +4994,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -4908,7 +5061,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5101,6 +5254,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -5111,7 +5297,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5134,7 +5320,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -6051,6 +6237,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -6661,7 +6856,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -6928,6 +7123,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zip" version = "8.6.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f9c8acf..9f6f2a0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ tauri-plugin-deep-link = "2.4" tauri-plugin-opener = "2.5" tauri-plugin-single-instance = { version = "2.4", features = ["deep-link"] } tauri-plugin-stronghold = "2.3" +tauri-plugin-updater = "2.10" thiserror = "2" time = { version = "0.3", features = ["formatting"] } tokio = { version = "1", features = ["sync"] } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 3cf8bf1..cbae54b 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "deep-link:default", "opener:default", "stronghold:default", - "stronghold:allow-remove-store-record" + "stronghold:allow-remove-store-record", + "updater:default" ] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9f65e6e..0831817 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2041,6 +2041,7 @@ pub fn run() { })) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let directory = app.path().app_data_dir()?; fs::create_dir_all(&directory)?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d24a07d..49ee09c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -25,6 +25,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "all", "icon": [], "category": "Utility", @@ -38,6 +39,12 @@ }, "opener": { "openUrl": true + }, + "updater": { + "endpoints": [ + "https://github.com/unique01082/folder-structure-sync/releases/latest/download/latest.json" + ], + "pubkey": "" } } } diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts index 13442ea..af668ef 100644 --- a/apps/desktop/src/auth.ts +++ b/apps/desktop/src/auth.ts @@ -12,7 +12,6 @@ import { type IWindow, type NavigateParams, type NavigateResponse, - type User, type UserManagerSettings, } from "oidc-client-ts"; diff --git a/docs/README.md b/docs/README.md index 519c205..72300c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,28 @@ # Rootline documentation -- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) -- [npm `folder-structure-sync@1.1.0` recovery](baseline/npm-1.1.0-recovery.md) -- [Hosted profile sync operations](hosted-profile-sync.md) - Authentik registration, API configuration, privacy contract, and fail-closed deployment gates. +Rootline by baole.space is a local-first, additive directory-structure synchronizer with a Node CLI, a Tauri desktop app, and optional hosted profile synchronization. + +## Users + +- [Configuration](configuration.md) - CLI flags, JSON configuration, desktop profiles, and hosted-sync build configuration. +- [Migrate from npm 1.1.0](migration-v1-to-v2.md) - Behavior changes and a safe migration sequence. +- [Privacy](privacy.md) - Exact local, hosted, and telemetry boundaries. +- [Security policy](../SECURITY.md) - Supported versions, reporting, and trust boundaries. + +## Operators and releasers + +- [Architecture](architecture.md) - Components, data ownership, and trust boundaries. +- [Hosted profile sync](hosted-profile-sync.md) - Authentik, API contract, reset semantics, and provider setup. +- [Operations](operations.md) - API/PostgreSQL deployment, migrations, health, backups, and incidents. +- [Release process](release.md) - CI matrix and fail-closed npm/API/desktop stable gates. + +## Contributors + +- [Contributing](../CONTRIBUTING.md) - Local setup, tests, and pull request workflow. +- [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - Approved product constraints. +- [npm 1.1.0 recovery](baseline/npm-1.1.0-recovery.md) - Published baseline and integrity evidence. + +## Related + +- [Rootline README](../README.md) - Product overview and supported installation paths. +- [Release process](release.md) - Distribution status and external gates. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9d43e15 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,45 @@ +# Rootline architecture + +## Components + +| Component | Ownership | +|---|---| +| `packages/contracts` | Stable domain, cloud, error, and DTO contracts | +| `packages/core` | Environment-independent snapshot, exclusion, planning, selection, and validation logic | +| `packages/cli` | Node filesystem adapter and the public `folder-sync` binary | +| `apps/desktop` | React workflow plus the Tauri 2 native boundary | +| `apps/api` | Optional NestJS/Prisma hosted profile synchronization | + +The npm CLI bundles its private workspace implementation into one public tarball. Desktop filesystem access, dialogs, SQLite, device identity, outbox, tokens, and remote sync live behind the Rust/native boundary. React receives projected profile and workflow state, not bearer tokens. + +## Local synchronization + +```text +source scan -> deterministic snapshot -> additive plan -> user selection + -> source/target revalidation -> ordered mkdir operations -> local history +``` + +The source is read-only. Rootline creates missing target directories and never copies files or deletes files/directories. Equal, ancestor, descendant, symlink, and Windows junction relationships are rejected. Excluded subtrees and directories containing `.ignore` are pruned. A stale source or target invalidates the plan before mutation. + +## Desktop persistence + +SQLite owns profiles, device identity, capped run history, account binding, mutation outbox, receipts, cursor, epoch, and lifecycle generations. Migrations are append-only and applied by the native process. Sign-out, account deletion, account switching, and epoch adoption rotate lifecycle state so an in-flight response cannot rebind or resurrect stale data. + +OIDC protocol state and tokens use Stronghold. Its per-install vault secret uses the operating-system credential manager. Tokens are not written to localStorage, SQLite, logs, or React state. + +## Hosted profile sync + +Hosted sync is optional and never needed for scanning or applying locally. Only an explicitly saved complete profile is eligible for upload after account consent. A profile includes its name, absolute source/target paths, exclusions, timestamps, and additive mode. Directory trees, file names/content, and run history are outside the cloud contract. + +The API validates static Authentik RS256 keys, exact issuer/audience, the verified subject, and `rootline:profiles:sync`. Tenant ownership cannot be selected in a request body. Server commit-arrival order is last-write-wins; deletes are tombstones; idempotent mutation receipts are content-bound. Account-data deletion rotates the epoch and stale devices must explicitly adopt the new epoch. + +## Distribution trust boundary + +Pull requests build code but never publish. Stable release workflows require exact `2.0.0` refs/confirmations, protected production environments, immutable versions, and complete credentials. npm uses provenance; the API migrates before deployment and must pass HTTPS health; desktop installers use Apple Developer ID/notarization or Windows Authenticode and every updater archive has a Tauri signature. No release path disables signatures. + +## Related + +- [Configuration](configuration.md) - Runtime and build-time settings. +- [Privacy](privacy.md) - Data inventory and retention. +- [Operations](operations.md) - Hosted deployment responsibilities. +- [Release process](release.md) - CI and distribution gates. diff --git a/docs/baseline/npm-1.1.0-recovery.md b/docs/baseline/npm-1.1.0-recovery.md index 9eeb167..44395b0 100644 --- a/docs/baseline/npm-1.1.0-recovery.md +++ b/docs/baseline/npm-1.1.0-recovery.md @@ -46,4 +46,5 @@ In the recovered `1.1.0` source, recursive scanning checks for a `.ignore` file ## Related - [Rootline documentation](../README.md) - Documentation navigation. +- [Migration to 2.0.0](../migration-v1-to-v2.md) - Safe user upgrade sequence. - [Rootline Desktop + CLI v2 implementation plan](../superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - The migration plan this evidence supports. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..60d3052 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,58 @@ +# Rootline configuration + +## CLI + +Rootline requires Node.js 20 or newer. The command is: + +```text +folder-sync [--dry-run] [--verbose] [--auto] [--config PATH] [--json] +``` + +Configuration resolution is deliberately narrow: an explicit `--config` path wins; otherwise Rootline reads `sync-config.json` only from the current working directory; if that file is absent, built-in exclusions and operating-system case behavior apply. Invalid explicit files fail—there is no silent fallback. + +```json +{ + "defaultExclusions": [".git", "node_modules", ".DS_Store", "dist", "build"], + "customExclusions": ["private-cache", "*.generated"], + "targetCaseSensitive": false +} +``` + +`defaultExclusions` replaces the built-in list when present. `customExclusions` is appended. Patterns match path segments and Rootline prunes the complete matching subtree. `targetCaseSensitive` defaults to `false` on macOS/Windows and `true` elsewhere; set it only when the target filesystem's actual semantics differ. + +`--dry-run` never creates a missing target. `--json` never prompts. Use `--auto --json` for non-interactive application, and treat exit codes `1` and `2` as failures. + +## Desktop profiles + +A saved profile contains a display name, absolute source and target paths, exclusions, timestamps, and additive sync mode. It is local by default. Changing a folder outside Rootline may require re-binding the profile before a new scan. The desktop always supports offline profiles and run history without authentication. + +## Optional hosted sync build variables + +Set either all three values or none: + +```dotenv +VITE_AUTHENTIK_ISSUER=https://auth.example.com/application/o/rootline/ +VITE_AUTHENTIK_CLIENT_ID=rootline-desktop +VITE_ROOTLINE_SYNC_API=https://rootline-api.example.com +``` + +Both URLs must use HTTPS. With no values, account controls are absent and offline behavior is unchanged. A partial or insecure configuration stops startup with an actionable error. The Authentik client is public: never add a client secret. + +## API environment + +```dotenv +DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require&sslaccept=strict +JWT_ISSUER=https://auth.example.com/application/o/rootline/ +JWT_AUDIENCE=rootline-desktop +JWT_JWKS_PATH=/run/secrets/rootline-authentik-jwks.json +RATE_LIMIT_PER_MINUTE=60 +PORT=3000 +``` + +All first four values are required. `JWT_JWKS_PATH` must be a mounted JSON Web Key Set containing at least one Authentik RS256 public key. Production database connections require `sslmode=require&sslaccept=strict` so Prisma verifies the server certificate. Secret values belong in the deployment provider, not environment files committed to git. + +## Related + +- [Architecture](architecture.md) - How configuration crosses trust boundaries. +- [Hosted profile sync](hosted-profile-sync.md) - Exact Authentik registration and API contract. +- [Operations](operations.md) - Production secret and migration handling. diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 8fb3165..139843f 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -41,7 +41,7 @@ Native sync calls are serialized. Each request captures the verified subject plu The API requires: ```dotenv -DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require +DATABASE_URL=postgresql://rootline:REDACTED@postgres.example.com:5432/rootline?sslmode=require&sslaccept=strict JWT_ISSUER=https://auth.example.com/application/o/rootline/ JWT_AUDIENCE=rootline-desktop JWT_JWKS_PATH=/run/secrets/rootline-authentik-jwks.json @@ -65,7 +65,7 @@ The stable deployment must provide TLS termination for the API, TLS validation f | Endpoint | Contract | |----------|----------| -| **`GET /healthz`** | Public liveness response; no account data | +| **`GET /healthz`** | Public liveness plus non-sensitive deployment build identity; no account data | | **`POST /v1/sync`** | Authenticated profile mutations and cursor delta | | **`DELETE /v1/account-data`** | Deletes hosted data and rotates the user's epoch | @@ -106,4 +106,6 @@ The API suite includes a real seam test that starts from the desktop's SQLite pr ## Related - [Rootline documentation](README.md) - Documentation navigation. +- [Configuration](configuration.md) - Desktop and API environment variables. +- [Operations](operations.md) - Production rollout, backups, and incident handling. - [Rootline Desktop + CLI v2 implementation plan](superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md) - Product constraints and acceptance criteria. diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..c358f9d --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,47 @@ +# Migrate from folder-structure-sync 1.1.0 to Rootline 2.0.0 + +## Before upgrading + +`2.0.0` keeps the npm package name `folder-structure-sync`, binary `folder-sync`, ISC license, `.ignore` subtree pruning, and the legacy `--dry-run`, `--verbose`, and `--auto` flags. It raises the minimum Node.js version to 20 and replaces the original interactive presentation with deterministic planning, explicit safety errors, JSON automation, and optional configuration paths. + +The stable release is not assumed to exist merely because this branch has version `2.0.0`. Confirm the version on [npm](https://www.npmjs.com/package/folder-structure-sync) before upgrading. + +## Safe migration + +1. Upgrade automation hosts to Node.js 20 or newer. +2. Preserve the current `sync-config.json` and verify it is strict JSON; comments are invalid. +3. Install `folder-structure-sync@2.0.0` only after npm shows that exact version. +4. Run the existing source/target pair with `--dry-run --json` and archive the output. +5. Resolve any `PATH_OVERLAP`, `INVALID_PATH`, or configuration error rather than bypassing it. +6. Run once interactively, or add `--auto --json` only after reviewing the plan. + +Example automation migration: + +```bash +folder-sync "$SOURCE" "$TARGET" --dry-run --json --config ./sync-config.json +folder-sync "$SOURCE" "$TARGET" --auto --json --config ./sync-config.json +``` + +## Behavior differences + +| Area | 1.1.0 | 2.0.0 | +|---|---|---| +| Node minimum | 12 | 20 | +| Missing target | Legacy creation behavior | Previewed or explicitly created | +| Root overlap/link aliases | Limited checks | Rejected before mutation | +| Automation output | Human-oriented | One JSON document with `--json` | +| Config selection | Working-directory file | Same default plus explicit `--config` | +| Desktop | None | Local-first macOS/Windows application | +| Cloud | None | Optional profile-only sync; CLI remains local-only | + +There is no v1 cloud or desktop database to migrate. Creating a desktop profile is an explicit local action. Profiles created before first sign-in remain unclaimed until the user chooses whether to upload them. + +## Rollback + +Keep a v1.1.0 lockfile or tarball reference during rollout. Because both versions only add directories, rolling the CLI binary back does not require undoing filesystem mutations. Do not delete directories to simulate rollback. The preserved tarball integrity and behavior evidence is in the baseline document. + +## Related + +- [npm 1.1.0 recovery](baseline/npm-1.1.0-recovery.md) - Integrity and unreachable-source evidence. +- [Configuration](configuration.md) - v2 flags and JSON schema. +- [Privacy](privacy.md) - Optional hosted profile implications. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..9624e30 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,57 @@ +# Rootline operations + +## Deployment order + +1. Provision encrypted PostgreSQL 16 with encrypted, tested backups. +2. Register the Authentik public client exactly as documented and mount a static RS256 JWKS. +3. Configure the API production environment without placing secrets in the image. +4. Build from the digest-pinned base and push a unique candidate image only if stable tag `rootline-api:2.0.0` does not already exist. +5. Apply every checked-in Prisma migration to the production database. +6. Deploy the immutable image through the configured HTTPS provider webhook. +7. Require `GET /healthz` to return `status: "ok"` and the unique build identity requested by the current workflow over HTTPS. +8. Promote that verified digest to stable tag `rootline-api:2.0.0`; never overwrite the stable tag. + +The release workflow enforces this order. A migration failure prevents deployment; a deployment or health failure prevents a successful stable release. + +## Local integration validation + +```bash +pnpm --filter @rootline/api test:e2e:postgres +``` + +This uses disposable PostgreSQL 16 and real migrations. For a manually managed test database: + +```bash +pnpm --filter @rootline/api prisma:generate +DATABASE_URL=postgresql://... pnpm --filter @rootline/api prisma:migrate:deploy +DATABASE_URL=postgresql://... pnpm --filter @rootline/api test:e2e +``` + +## Health and logs + +`/healthz` is public and contains only health state plus a non-sensitive deployment build identity; it contains no account data. Monitor non-2xx responses, migration failures, authentication failure rates, request-limit rejections, and PostgreSQL capacity. Do not add request-body or authorization-header logging during incident response. Absolute profile paths must remain absent from production logs. + +The built-in rate limiter is process-local. Run one API replica for this release. A shared subject-keyed limiter is required before horizontal scaling. + +## Backup and recovery + +- Encrypt database connections, storage, snapshots, and off-site backups. +- Restore backups into an isolated environment and validate migrations plus `/healthz` regularly. +- Treat a restore as hosted profile recovery only; filesystem trees and run history are not server data. +- Coordinate restore timestamps with epoch/account-deletion semantics so deleted accounts are not accidentally reintroduced. + +## Key rotation and incidents + +For Authentik signing-key rotation, mount a JWKS containing old and new public keys, restart, rotate the issuer, wait for old access tokens to expire, then remove the retired key. Never choose a JWKS URL from an unverified token header. + +If a deployment secret leaks, stop the release, rotate it at the provider, replace the repository environment secret, invalidate affected sessions when applicable, and only then re-run preflight. If the API becomes unhealthy after migration, do not rewrite or delete migration history; halt deployment and use a reviewed forward migration or provider rollback compatible with the applied schema. + +## External production gates + +The repository cannot complete Authentik registration, production PostgreSQL/TLS/backup provisioning, provider webhook setup, Apple notarization, Windows certificate issuance, or updater-key custody. Until operators configure and exercise these gates, stable distribution remains blocked and must not be described as live. + +## Related + +- [Hosted profile sync](hosted-profile-sync.md) - Authentik and protocol details. +- [Release process](release.md) - Workflow inputs and validation. +- [Privacy](privacy.md) - Operational data limits. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..da1c1cc --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,35 @@ +# Rootline privacy + +Rootline is local-first and has no usage telemetry. + +## Data inventory + +| Data | Local desktop | Hosted API | Product telemetry | +|---|---:|---:|---:| +| Saved profile name and absolute source/target paths | Yes | Only after sign-in and explicit consent | Never | +| Profile exclusions and timestamps | Yes | Only with the saved profile | Never | +| Directory tree, file names, or file contents | Used transiently for local scans | Never | Never | +| Run history and selected plan | Yes | Never | Never | +| Device ID, cursor, epoch, mutation receipts | Yes | Protocol-scoped values only | Never | +| OIDC tokens/protocol state | Native encrypted vault | Token verified in memory | Never | + +The CLI has no cloud profile feature and sends no product data. The desktop works without an account. Hosted sync is a convenience for complete saved profile documents, not a backup of filesystem content. + +## Consent and account separation + +Profiles made before sign-in are not silently claimed. The user chooses whether to upload existing profiles or keep them local. Account switching clears the previous subject's cursor and queued mutations. Keeping local data on sign-out does not make it eligible for a later account automatically. + +Because profiles contain absolute paths, they can reveal usernames, drive layouts, organization names, or project names. Use hosted sync only when that disclosure is appropriate. The API derives tenant ownership from the verified OIDC subject and never accepts a tenant selector from the client. + +## Retention and deletion + +Local profiles and capped run history remain until the user removes them or chooses local-data removal during sign-out/account removal. Hosted mutation receipts are retained for 90 days for idempotency. Deleting account data removes hosted profiles, tombstones, changes, and receipts, then rotates the epoch so a stale device cannot silently restore them. + +Production logs are metadata-only. Operators must not log bearer tokens, request bodies, profile fields, or absolute paths. Backups containing hosted profiles must be encrypted and governed by the deployment's retention policy. + +## Related + +- [Security policy](../SECURITY.md) - Vulnerability handling and enforcement boundaries. +- [Architecture](architecture.md) - Local/native/API ownership. +- [Hosted profile sync](hosted-profile-sync.md) - Account deletion and reset protocol. +- [Release process](release.md) - Distribution gates that preserve these boundaries. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..df2c5b7 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,60 @@ +# Rootline 2.0.0 release process + +Stable releases are performed only by the checked-in GitHub Actions workflows. Local validation never publishes, deploys, signs, or creates a GitHub release. + +## Pull request CI + +`.github/workflows/ci.yml` runs: + +- ESLint, TypeScript typecheck, unit tests, builds, workflow contract tests; +- isolated npm tarball install and `folder-sync` execution; +- real PostgreSQL 16 migration/API/desktop seam integration; +- Rust `fmt`, `clippy -D warnings`, and tests; +- Tauri compile matrices for macOS Universal, Windows x64, and Windows ARM64. + +Windows ARM64 uses GitHub's native `windows-11-arm` runner. CI uses `--no-bundle` for compile coverage; it does not create an unsigned installer. + +Validate workflow syntax/contracts without publishing: + +```bash +pnpm validate:workflows +pnpm test:release +``` + +## npm + +`release-npm.yml` runs only from the existing `v2.0.0` tag (with an additional exact confirmation for manual runs), checks the package version, and stops if `NPM_TOKEN` is missing. An unprivileged job reruns quality/unit gates, installs the packed CLI in isolation, and uploads the exact tarball plus an independent checksum output. A minimal protected `npm-production` job verifies that checksum and packed identity, then exposes `NPM_TOKEN` only to `npm publish` with provenance and public access. + +Required external gate: repository secret `NPM_TOKEN` with publish access and approval for the `npm-production` environment. + +## API + +`release-api.yml` requires the existing `v2.0.0` tag, an exact manual confirmation, and every production database/auth/deployment secret. It uses a digest-pinned Node base and refuses to run if GHCR tag `rootline-api:2.0.0` already exists. It pushes a unique candidate tag, deploys by the resulting immutable digest, applies the checked-in migrations, calls the HTTPS deployment webhook with the digest and unique workflow build identity, then polls HTTPS `/healthz` until that exact identity is live. Only after health succeeds does it promote that verified digest to `rootline-api:2.0.0`; a failed attempt leaves stable identity unused and safely retryable. A healthy response from an older replica cannot pass the gate. + +Required external gates: `ROOTLINE_API_DATABASE_URL`, `ROOTLINE_API_DEPLOY_WEBHOOK_URL`, `ROOTLINE_API_DEPLOY_TOKEN`, `ROOTLINE_API_BASE_URL`, `ROOTLINE_JWT_ISSUER`, `ROOTLINE_JWT_AUDIENCE`, `ROOTLINE_JWT_JWKS_B64`, GHCR permissions, and `api-production` approval. PostgreSQL TLS/backup and provider runtime configuration are operator responsibilities. + +## Desktop + +`release-desktop.yml` runs only from the existing `v2.0.0` tag. macOS Universal is Developer ID signed, notarized, and stapled. Windows x64 and ARM64 installer/updater executables are Authenticode signed and timestamped. Tauri updater artifacts cover the default `darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, and `windows-aarch64` runtime keys; `latest.json` is generated from their non-empty `.sig` files. Release publication occurs only after code-signature/staple verification succeeds. + +Required external gates: + +- Apple: `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, `APPLE_KEYCHAIN_PASSWORD`. +- Windows: `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`. +- Updater: `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and matching public environment variable `TAURI_UPDATER_PUBLIC_KEY`; custody and rotation must be defined before the first stable release. +- Hosted profile build variables: `VITE_AUTHENTIK_ISSUER`, `VITE_AUTHENTIK_CLIENT_ID`, `VITE_ROOTLINE_SYNC_API`. +- Protected `desktop-production` approval and a reviewed `v2.0.0` git tag. + +Missing values produce an actionable preflight error. Do not replace a missing production identity with ad-hoc signing, an empty updater key, `--skip-stapling`, or an unsigned artifact. + +## Release verification and rollback + +After an authorized workflow completes, verify npm provenance and a clean install; image digest, migration log, and HTTPS health; Apple signature/notarization/staple; Windows Authenticode status/timestamp; updater URLs, checksums, and signatures; and that portal wording reflects actual availability. If any platform fails, keep the release blocked rather than publishing a partial stable claim. + +Published versions and migrations are immutable. Fix forward with a new version and migration. Revoke compromised installers/updater keys through the release provider and rotate keys; never overwrite `2.0.0` with different bytes. + +## Related + +- [Operations](operations.md) - API rollout and incidents. +- [Security policy](../SECURITY.md) - Stable supply-chain controls. +- [Privacy](privacy.md) - Data boundaries that releases must preserve. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3161d1e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,24 @@ +import eslint from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["**/dist/**", "**/target/**", "apps/desktop/src-tauri/gen/**"], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{js,mjs,ts,tsx}"], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + rules: { + "no-undef": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, +); diff --git a/package.json b/package.json index 16e587b..40b825f 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,21 @@ "packageManager": "pnpm@10.33.0", "scripts": { "build": "pnpm -r --if-present run build", + "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", "test": "pnpm -r --if-present run test", + "test:unit": "pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", + "test:release": "node --test tests/*.test.mjs", + "validate:workflows": "node --test tests/release-workflows.test.mjs", "typecheck": "pnpm -r --if-present run typecheck" }, "devDependencies": { + "@eslint/js": "^9.39.1", "@types/node": "^24.0.0", + "eslint": "^9.39.1", + "globals": "^16.5.0", "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript-eslint": "^8.46.1", + "vitest": "^3.2.4", + "yaml": "^2.8.1" } } diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..94c4e20 --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025, folder-structure-sync contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..9b6d8e2 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,14 @@ +# Rootline CLI + +`folder-structure-sync` provides the `folder-sync` command for safe, additive directory-structure synchronization. It creates missing directories only; it does not copy, move, rename, or delete files. + +Rootline `2.0.0` requires Node.js 20 or newer. Until the protected release workflow succeeds, install from a reviewed local tarball rather than assuming `2.0.0` is live on npm. + +```bash +folder-sync ./source ./target --dry-run +folder-sync ./source ./target --auto --json +``` + +Use `--dry-run` to preview. `--json` never prompts; combine it with `--auto` for non-interactive application. Run `folder-sync --help` for the complete command reference. + +Documentation, source, security policy, and release status: diff --git a/packages/cli/package.json b/packages/cli/package.json index 026b3dd..b0e3439 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,18 +3,28 @@ "version": "2.0.0", "description": "Rootline by baole.space command-line interface", "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/unique01082/folder-structure-sync.git" + }, + "engines": { + "node": ">=20" + }, "type": "module", "bin": { "folder-sync": "./dist/index.js" }, "files": ["dist"], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/index.js --sourcemap", "test": "vitest run", + "test:unit": "vitest run --exclude test/package-smoke.test.ts", + "test:pack": "vitest run test/package-smoke.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, - "dependencies": { + "devDependencies": { "@rootline/contracts": "workspace:*", - "@rootline/core": "workspace:*" + "@rootline/core": "workspace:*", + "esbuild": "^0.28.2" } } diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts index 8891ba8..7812324 100644 --- a/packages/cli/test/package-smoke.test.ts +++ b/packages/cli/test/package-smoke.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, realpath, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, realpath, rm } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,24 +25,29 @@ afterAll(async () => { }); describe("published CLI package", () => { - it("installs from packed workspace tarballs and runs the folder-sync binary", async () => { + it("installs from the public CLI tarball alone and runs the folder-sync binary", async () => { execute("pnpm", ["--filter", "@rootline/contracts", "build"]); execute("pnpm", ["--filter", "@rootline/core", "build"]); execute("pnpm", ["--filter", "folder-structure-sync", "build"]); - execute("pnpm", ["--filter", "@rootline/contracts", "pack", "--pack-destination", scratch]); - execute("pnpm", ["--filter", "@rootline/core", "pack", "--pack-destination", scratch]); execute("pnpm", ["--filter", "folder-structure-sync", "pack", "--pack-destination", scratch]); const install = join(scratch, "install"); const source = join(scratch, "source"); const target = join(scratch, "target"); await Promise.all([mkdir(install), mkdir(join(source, "nested"), { recursive: true })]); - const tarballs = [ - join(scratch, "rootline-contracts-2.0.0.tgz"), - join(scratch, "rootline-core-2.0.0.tgz"), - join(scratch, "folder-structure-sync-2.0.0.tgz"), - ]; - execute("npm", ["install", "--ignore-scripts", ...tarballs], install); + const tarball = join(scratch, "folder-structure-sync-2.0.0.tgz"); + const listing = execute("tar", ["-tzf", tarball]).stdout.split("\n"); + expect(listing).toEqual(expect.arrayContaining(["package/LICENSE", "package/README.md"])); + execute("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); + const packedManifest = JSON.parse(await readFile(join(scratch, "package", "package.json"), "utf8")); + expect(packedManifest).toMatchObject({ + engines: { node: ">=20" }, + repository: { + type: "git", + url: "git+https://github.com/unique01082/folder-structure-sync.git", + }, + }); + execute("npm", ["install", "--ignore-scripts", tarball], install); const result = execute(join(install, "node_modules", ".bin", "folder-sync"), [source, target, "--auto", "--json"], install); expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b244c..24c1bc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,30 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.5 '@types/node': specifier: ^24.0.0 version: 24.13.3 + eslint: + specifier: ^9.39.1 + version: 9.39.5(jiti@2.7.0) + globals: + specifier: ^16.5.0 + version: 16.5.0 typescript: specifier: ^5.9.2 version: 5.9.3 + typescript-eslint: + specifier: ^8.46.1 + version: 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) + yaml: + specifier: ^2.8.1 + version: 2.9.0 apps/api: dependencies: @@ -89,7 +104,7 @@ importers: version: 4.23.12 vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) apps/desktop: dependencies: @@ -105,6 +120,9 @@ importers: '@tauri-apps/plugin-stronghold': specifier: ^2.3.0 version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.1 oidc-client-ts: specifier: ^3.3.0 version: 3.5.0 @@ -135,7 +153,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)) + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) axe-core: specifier: ^4.10.3 version: 4.13.0 @@ -144,19 +162,22 @@ importers: version: 26.1.0 vite: specifier: ^7.1.2 - version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + version: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12) + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0) packages/cli: - dependencies: + devDependencies: '@rootline/contracts': specifier: workspace:* version: link:../contracts '@rootline/core': specifier: workspace:* version: link:../core + esbuild: + specifier: ^0.28.2 + version: 0.28.2 packages/contracts: {} @@ -448,6 +469,64 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -842,6 +921,9 @@ packages: '@tauri-apps/plugin-stronghold@2.3.1': resolution: {integrity: sha512-zFbD1Apk/VFdWaoGaoKcouRrZnzLFiNY9b1KDeBaN47sMaMHRYIa+ZDhvbzMOyH314+OHCQBXfe8I/ph59Lp9g==} + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -923,6 +1005,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} @@ -973,6 +1058,65 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1012,14 +1156,31 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -1051,6 +1212,13 @@ packages: resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.11.14: resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} @@ -1060,6 +1228,13 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1099,6 +1274,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} @@ -1106,6 +1285,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1126,6 +1309,13 @@ packages: class-validator@0.14.4: resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1133,6 +1323,9 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -1174,6 +1367,10 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1204,6 +1401,9 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge-ts@7.1.5: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} @@ -1306,9 +1506,59 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1328,6 +1578,15 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -1340,6 +1599,10 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@21.3.4: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} @@ -1348,6 +1611,17 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -1388,10 +1662,26 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1431,6 +1721,22 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -1442,12 +1748,23 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iterare@1.2.1: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} @@ -1465,6 +1782,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + js-yaml@5.2.1: resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} hasBin: true @@ -1483,6 +1804,15 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1502,6 +1832,13 @@ packages: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.11: resolution: {integrity: sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==} @@ -1509,6 +1846,10 @@ packages: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -1527,6 +1868,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -1594,6 +1938,13 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1606,6 +1957,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1647,6 +2001,22 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -1665,6 +2035,14 @@ packages: resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} engines: {node: '>= 0.4.0'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -1695,6 +2073,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1766,6 +2148,10 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1814,6 +2200,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1858,6 +2252,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -1873,6 +2271,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + swagger-ui-dist@5.32.8: resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} @@ -1928,6 +2330,12 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1936,6 +2344,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -1947,6 +2359,13 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1973,6 +2392,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2082,11 +2504,20 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2112,6 +2543,15 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -2338,6 +2778,68 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2631,6 +3133,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -2733,6 +3239,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 @@ -2795,7 +3303,98 @@ snapshots: '@types/validator@13.15.10': {} - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12))': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -2803,7 +3402,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -2815,13 +3414,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -2854,10 +3453,27 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} append-field@1.0.0: {} @@ -2878,6 +3494,10 @@ snapshots: axe-core@4.13.0: {} + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + baseline-browser-mapping@2.11.14: {} body-parser@2.3.0: @@ -2894,6 +3514,15 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.14 @@ -2939,6 +3568,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001809: {} chai@5.3.3: @@ -2949,6 +3580,11 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.3: {} chokidar@4.0.3: @@ -2969,12 +3605,20 @@ snapshots: libphonenumber-js: 1.13.11 validator: 13.15.35 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 component-emitter@1.3.1: {} + concat-map@0.0.1: {} + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -3005,6 +3649,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css.escape@1.5.1: {} cssstyle@4.6.0: @@ -3027,6 +3677,8 @@ snapshots: deep-eql@5.0.2: {} + deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} defu@6.1.7: {} @@ -3131,10 +3783,82 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + etag@1.8.1: {} expect-type@1.4.0: {} @@ -3178,12 +3902,22 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@21.3.4: dependencies: '@tokenizer/inflate': 0.4.1 @@ -3204,6 +3938,18 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -3256,8 +4002,18 @@ snapshots: nypm: 0.6.9 pathe: 2.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -3304,16 +4060,35 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inherits@2.0.4: {} ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} + isexe@2.0.0: {} + iterare@1.2.1: {} jiti@2.7.0: {} @@ -3324,6 +4099,10 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + js-yaml@5.2.1: dependencies: argparse: 2.0.1 @@ -3357,6 +4136,12 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonwebtoken@9.0.3: @@ -3385,10 +4170,23 @@ snapshots: jwt-decode@4.0.0: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + libphonenumber-js@1.13.11: {} load-esm@1.0.3: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -3401,6 +4199,8 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} lodash@4.18.1: {} @@ -3445,6 +4245,14 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + ms@2.1.3: {} multer@2.2.0: @@ -3456,6 +4264,8 @@ snapshots: nanoid@3.3.18: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} node-fetch-native@1.6.7: {} @@ -3488,6 +4298,27 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -3507,6 +4338,10 @@ snapshots: pause: 0.0.1 utils-merge: 1.0.1 + path-exists@4.0.0: {} + + path-key@3.1.1: {} + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -3533,6 +4368,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -3602,6 +4439,8 @@ snapshots: reflect-metadata@0.2.2: {} + resolve-from@4.0.0: {} + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -3691,6 +4530,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -3739,6 +4584,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -3769,6 +4616,10 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + swagger-ui-dist@5.32.8: dependencies: '@scarf/scarf': 1.4.0 @@ -3814,6 +4665,10 @@ snapshots: dependencies: punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: {} tsx@4.23.12: @@ -3822,6 +4677,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -3835,6 +4694,17 @@ snapshots: typedarray@0.0.6: {} + typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uid@2.0.2: @@ -3853,6 +4723,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -3861,13 +4735,13 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12): + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -3882,7 +4756,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12): + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -3895,12 +4769,13 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.12 + yaml: 2.9.0 - vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12): + vitest@3.2.7(@types/node@24.13.3)(jiti@2.7.0)(jsdom@26.1.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -3918,8 +4793,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) - vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -3955,11 +4830,17 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrappy@1.0.2: {} ws@8.21.3: {} @@ -3969,3 +4850,7 @@ snapshots: xmlchars@2.2.0: {} yallist@3.1.1: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} diff --git a/scripts/create-updater-manifest.mjs b/scripts/create-updater-manifest.mjs new file mode 100644 index 0000000..3be1689 --- /dev/null +++ b/scripts/create-updater-manifest.mjs @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const [artifactDirectory, releaseBaseUrl, outputPath] = process.argv.slice(2); +if (!artifactDirectory || !releaseBaseUrl || !outputPath) { + throw new Error("Usage: create-updater-manifest "); +} +const baseUrl = new URL(releaseBaseUrl); +if (baseUrl.protocol !== "https:") throw new Error("Updater release base URL must use HTTPS."); + +const artifacts = { + "darwin-aarch64": "rootline-2.0.0-darwin-universal.app.tar.gz", + "darwin-x86_64": "rootline-2.0.0-darwin-universal.app.tar.gz", + "windows-x86_64": "rootline-2.0.0-windows-x86_64-setup.exe", + "windows-aarch64": "rootline-2.0.0-windows-aarch64-setup.exe", +}; +const platforms = Object.fromEntries(Object.entries(artifacts).map(([platform, fileName]) => { + const signature = readFileSync(join(artifactDirectory, `${fileName}.sig`), "utf8").trim(); + if (!signature) throw new Error(`Updater signature is empty for ${platform}.`); + readFileSync(join(artifactDirectory, fileName)); + return [platform, { + signature, + url: new URL(encodeURIComponent(fileName), `${baseUrl.toString().replace(/\/?$/, "/")}`).toString(), + }]; +})); + +writeFileSync(outputPath, `${JSON.stringify({ + version: "2.0.0", + notes: "Rootline by baole.space 2.0.0", + pub_date: process.env.RELEASE_PUBLISHED_AT || new Date().toISOString(), + platforms, +}, null, 2)}\n`); diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs new file mode 100644 index 0000000..5dd5a25 --- /dev/null +++ b/tests/release-workflows.test.mjs @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { parse } from "yaml"; + +const root = process.cwd(); +const workflows = ["ci.yml", "release-npm.yml", "release-api.yml", "release-desktop.yml"]; + +function workflow(name) { + return readFileSync(join(root, ".github", "workflows", name), "utf8"); +} + +function parsedWorkflow(name) { + return parse(workflow(name)); +} + +function jobScopedSecrets(name) { + return Object.entries(parsedWorkflow(name).jobs).flatMap(([jobName, job]) => + Object.entries(job.env ?? {}) + .filter(([, value]) => String(value).includes("secrets.")) + .map(([key]) => `${jobName}.${key}`), + ); +} + +test("all workflows are valid YAML", () => { + for (const name of workflows) { + assert.doesNotThrow(() => parsedWorkflow(name)); + } +}); + +test("CI covers TypeScript quality, real PostgreSQL, Rust, npm smoke, and the supported Tauri targets", () => { + const ci = workflow("ci.yml"); + for (const expected of [ + "pnpm lint", + "pnpm typecheck", + "pnpm test:unit", + "postgres:16-alpine", + "prisma:migrate:deploy", + "test:e2e", + "cargo fmt --check", + "cargo clippy", + "cargo test", + "test:pack", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "windows-11-arm", + ]) assert.match(ci, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + const postgresSteps = parsedWorkflow("ci.yml").jobs["postgres-integration"].steps; + assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("libwebkit2gtk-4.1-dev"))); +}); + +test("npm 2.0.0 release fails closed before provenance publishing", () => { + const release = workflow("release-npm.yml"); + for (const expected of [ + "refs/tags/v2.0.0", + "NPM_TOKEN", + "test:pack", + "npm publish", + "--provenance", + "--access public", + "sha256sum", + "actions/upload-artifact", + "actions/download-artifact", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(release, /needs:\s*preflight/); + assert.match(release, /if \[ "\$\{GITHUB_REF\}" != "refs\/tags\/v2\.0\.0" \]/); + assert.equal(parsedWorkflow("release-npm.yml").jobs.preflight.environment, "npm-production"); + assert.deepEqual(jobScopedSecrets("release-npm.yml"), []); + const npmJobs = parsedWorkflow("release-npm.yml").jobs; + assert.equal(npmJobs.pack.permissions["id-token"], undefined); + assert.equal(npmJobs.pack.outputs.checksum, "${{ steps.checksum.outputs.sha256 }}"); + assert.doesNotMatch(JSON.stringify(npmJobs.publish.steps), /pnpm install|actions\/checkout/); + const publishSetup = npmJobs.publish.steps.find((step) => String(step.uses ?? "").includes("actions/setup-node")); + assert.equal(publishSetup.with["registry-url"], "https://registry.npmjs.org"); + assert.equal(publishSetup.with.cache, undefined); + const publishStep = npmJobs.publish.steps.find((step) => String(step.run ?? "").includes("npm publish")); + assert.equal(publishStep.env.NODE_AUTH_TOKEN, "${{ secrets.NPM_TOKEN }}"); + const manifest = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf8")); + assert.deepEqual(manifest.repository, { + type: "git", + url: "git+https://github.com/unique01082/folder-structure-sync.git", + }); + assert.equal(manifest.engines.node, ">=20"); +}); + +test("API release gates registry, migration, deployment, and HTTPS health secrets", () => { + const release = workflow("release-api.yml"); + for (const expected of [ + "ROOTLINE_API_DATABASE_URL", + "ROOTLINE_API_DEPLOY_WEBHOOK_URL", + "ROOTLINE_API_BASE_URL", + "ROOTLINE_JWT_JWKS_B64", + "sslaccept", + "prisma migrate deploy", + "/healthz", + "docker/build-push-action", + "Refuse to overwrite stable image tag", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(release, /needs:\s*preflight/); + assert.match(release, /refs\/tags\/v2\.0\.0/); + assert.match(release, /steps\.build\.outputs\.digest/); + assert.match(release, /image=.*@\$\{DIGEST\}/); + assert.match(release, /ROOTLINE_BUILD_ID/); + assert.match(release, /EXPECTED_BUILD_ID/); + assert.match(release, /\.buildId == env\.EXPECTED_BUILD_ID/); + assert.match(release, /sslmode"\) !== "require"/); + assert.match(release, /sslaccept"\) !== "strict"/); + assert.equal(parsedWorkflow("release-api.yml").jobs.preflight.environment, "api-production"); + assert.deepEqual(jobScopedSecrets("release-api.yml"), []); + const apiJobs = parsedWorkflow("release-api.yml").jobs; + assert.deepEqual(apiJobs.promote.needs, ["image", "health"]); + assert.match(release, /candidate-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); + assert.match(JSON.stringify(apiJobs.promote.steps), /imagetools create --tag/); + const dockerfile = readFileSync(join(root, "apps", "api", "Dockerfile"), "utf8"); + assert.match(dockerfile, /^FROM node:20-bookworm-slim@sha256:[a-f0-9]{64} AS base$/m); +}); + +test("desktop release requires platform signing and updater signing for every stable artifact", () => { + const release = workflow("release-desktop.yml"); + for (const expected of [ + "APPLE_CERTIFICATE", + "APPLE_CERTIFICATE_PASSWORD", + "APPLE_SIGNING_IDENTITY", + "APPLE_ID", + "APPLE_PASSWORD", + "APPLE_TEAM_ID", + "WINDOWS_CERTIFICATE", + "WINDOWS_CERTIFICATE_PASSWORD", + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", + "TAURI_UPDATER_PUBLIC_KEY", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "latest.json", + ]) assert.match(release, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.doesNotMatch(release, /TAURI_SIGNING_PRIVATE_KEY:\s*["']?["']?\s*$/m); + assert.doesNotMatch(release, /adhoc|ad-hoc|disable.*sign/i); + assert.doesNotMatch(release, /nsis\.zip/); + assert.match(release, /setup\.exe\.sig/); + assert.match(release, /needs:\s*preflight/); + assert.equal(parsedWorkflow("release-desktop.yml").jobs.preflight.environment, "desktop-production"); + assert.deepEqual(jobScopedSecrets("release-desktop.yml"), []); +}); + +test("desktop updater is registered and serves every default runtime target", () => { + const config = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "tauri.conf.json"), "utf8")); + const cargo = readFileSync(join(root, "apps", "desktop", "src-tauri", "Cargo.toml"), "utf8"); + const rust = readFileSync(join(root, "apps", "desktop", "src-tauri", "src", "lib.rs"), "utf8"); + const capability = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "capabilities", "default.json"), "utf8")); + const manifest = readFileSync(join(root, "scripts", "create-updater-manifest.mjs"), "utf8"); + + assert.equal(config.bundle.createUpdaterArtifacts, true); + assert.deepEqual(config.plugins.updater.endpoints, [ + "https://github.com/unique01082/folder-structure-sync/releases/latest/download/latest.json", + ]); + assert.match(cargo, /tauri-plugin-updater/); + assert.match(rust, /tauri_plugin_updater::Builder::new\(\)\.build\(\)/); + assert.ok(capability.permissions.includes("updater:default")); + for (const target of ["darwin-aarch64", "darwin-x86_64", "windows-x86_64", "windows-aarch64"]) { + assert.match(manifest, new RegExp(`\\"${target}\\"`)); + } +}); diff --git a/tests/updater-manifest.test.mjs b/tests/updater-manifest.test.mjs new file mode 100644 index 0000000..66ccad2 --- /dev/null +++ b/tests/updater-manifest.test.mjs @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +test("builds a complete updater manifest for every supported runtime target", (context) => { + const directory = mkdtempSync(join(tmpdir(), "rootline-updater-")); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const artifacts = [ + ["rootline-2.0.0-darwin-universal.app.tar.gz", "signature-darwin-universal"], + ["rootline-2.0.0-windows-x86_64-setup.exe", "signature-windows-x86_64"], + ["rootline-2.0.0-windows-aarch64-setup.exe", "signature-windows-aarch64"], + ]; + for (const [name, signature] of artifacts) { + writeFileSync(join(directory, name), "artifact"); + writeFileSync(join(directory, `${name}.sig`), signature); + } + const output = join(directory, "latest.json"); + + execFileSync(process.execPath, ["scripts/create-updater-manifest.mjs", directory, "https://github.com/example/rootline/releases/download/v2.0.0", output], { + cwd: process.cwd(), + env: { ...process.env, RELEASE_PUBLISHED_AT: "2026-08-15T00:00:00.000Z" }, + }); + + const manifest = JSON.parse(readFileSync(output, "utf8")); + assert.equal(manifest.version, "2.0.0"); + assert.equal(manifest.pub_date, "2026-08-15T00:00:00.000Z"); + assert.deepEqual(Object.keys(manifest.platforms).sort(), [ + "darwin-aarch64", + "darwin-x86_64", + "windows-aarch64", + "windows-x86_64", + ]); + assert.match(manifest.platforms["windows-aarch64"].url, /rootline-2\.0\.0-windows-aarch64-setup\.exe$/); + assert.equal(manifest.platforms["darwin-aarch64"].signature, "signature-darwin-universal"); + assert.equal(manifest.platforms["darwin-x86_64"].signature, "signature-darwin-universal"); +}); From 5704843f0e2f90d7c7f64a51c94c7087e4464181 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 09:13:11 +0700 Subject: [PATCH 16/26] fix: close Rootline release review gaps --- .github/workflows/release-api.yml | 2 +- .github/workflows/release-desktop.yml | 36 ++++++++++++++ .github/workflows/release-npm.yml | 2 +- docs/architecture.md | 2 +- docs/hosted-profile-sync.md | 2 +- docs/privacy.md | 2 +- docs/release.md | 8 +-- .../2026-08-15-rootline-desktop-cli-v2.md | 4 +- packages/contracts/package.json | 3 +- tests/release-workflows.test.mjs | 49 ++++++++++++++++++- 10 files changed, 97 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-api.yml b/.github/workflows/release-api.yml index b638908..0b761f6 100644 --- a/.github/workflows/release-api.yml +++ b/.github/workflows/release-api.yml @@ -132,7 +132,7 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@49933ea5288ca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 cache: pnpm diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index d2ae29d..27d43fa 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -23,6 +23,14 @@ jobs: environment: desktop-production steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Install minisign for updater key-pair proof + run: sudo apt-get update && sudo apt-get install --yes minisign - name: Require stable tag, release confirmation, signing credentials, and hosted profile endpoints env: CONFIRMATION: ${{ inputs.confirmation }} @@ -63,6 +71,34 @@ jobs: case "${VITE_AUTHENTIK_ISSUER}" in https://*) ;; *) echo "::error::VITE_AUTHENTIK_ISSUER must use HTTPS."; exit 1 ;; esac case "${VITE_ROOTLINE_SYNC_API}" in https://*) ;; *) echo "::error::VITE_ROOTLINE_SYNC_API must use HTTPS."; exit 1 ;; esac node -e "const p=require('./apps/desktop/package.json'); if(p.version !== '2.0.0') throw new Error('apps/desktop/package.json must be version 2.0.0')" + - name: Prove updater private key, password, and public key match + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + run: | + set -eu + challenge="$RUNNER_TEMP/rootline-updater-key-pair-challenge" + signature="$challenge.sig" + public_key="$RUNNER_TEMP/rootline-updater-public.key" + minisign_signature="$RUNNER_TEMP/rootline-updater-challenge.minisig" + printf '%s\n' "rootline-updater-key-pair-v2.0.0-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" > "$challenge" + if ! pnpm --filter @rootline/desktop tauri signer sign "$challenge" >/dev/null; then + echo "::error::Stable desktop release blocked: TAURI_SIGNING_PRIVATE_KEY or its password is invalid." + exit 1 + fi + if ! printf '%s' "$TAURI_UPDATER_PUBLIC_KEY" | base64 --decode > "$public_key"; then + echo "::error::Stable desktop release blocked: TAURI_UPDATER_PUBLIC_KEY is not valid base64." + exit 1 + fi + if ! base64 --decode < "$signature" > "$minisign_signature"; then + echo "::error::Stable desktop release blocked: Tauri produced an invalid updater signature." + exit 1 + fi + if ! minisign -Vm "$challenge" -p "$public_key" -x "$minisign_signature" >/dev/null; then + echo "::error::Stable desktop release blocked: private key, password, and public key do not form one updater keypair." + exit 1 + fi macos-universal: needs: preflight diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index fe3650c..4817c77 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -38,7 +38,7 @@ jobs: exit 1 fi if [ -z "${NPM_TOKEN}" ]; then - echo "::error::Stable npm release blocked. Configure the NPM_TOKEN repository secret with publish access to folder-structure-sync." + echo "::error::Stable npm release blocked. Configure NPM_TOKEN as a protected npm-production environment secret with publish access to folder-structure-sync." exit 1 fi node -e "const p=require('./packages/cli/package.json'); if(p.version !== '2.0.0') throw new Error('packages/cli/package.json must be version 2.0.0')" diff --git a/docs/architecture.md b/docs/architecture.md index 9d43e15..04bf20a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ | Component | Ownership | |---|---| -| `packages/contracts` | Stable domain, cloud, error, and DTO contracts | +| `packages/contracts` | Private workspace package exporting stable domain, cloud, error, and DTO contracts | | `packages/core` | Environment-independent snapshot, exclusion, planning, selection, and validation logic | | `packages/cli` | Node filesystem adapter and the public `folder-sync` binary | | `apps/desktop` | React workflow plus the Tauri 2 native boundary | diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 139843f..64525d5 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -73,7 +73,7 @@ Both account endpoints require an RS256 token with the configured issuer, audien The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. -Server commit arrival order is last-write-wins. Every accepted mutation advances a per-user revision, deletes become tombstones, and mutation receipts remain idempotent for 90 days. Account deletion clears profiles, tombstones, changes, and receipts, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. +Server commit arrival order is last-write-wins. Every accepted mutation advances a per-user revision, deletes become tombstones, and mutation receipts remain eligible for idempotent replay for 90 days. Expired receipts are purged opportunistically during a later sync. Account deletion clears profiles, tombstones, changes, and receipts, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. Mutation IDs are bound to a canonical content hash; reuse with different content returns 409 instead of silently dropping a change. Delta pages contain at most 100 records and approximately 1 MiB of record JSON. `hasMore` and the returned cursor let the desktop drain long-offline deltas while enforcing a 2 MiB streaming response cap. diff --git a/docs/privacy.md b/docs/privacy.md index da1c1cc..0cb9c78 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -23,7 +23,7 @@ Because profiles contain absolute paths, they can reveal usernames, drive layout ## Retention and deletion -Local profiles and capped run history remain until the user removes them or chooses local-data removal during sign-out/account removal. Hosted mutation receipts are retained for 90 days for idempotency. Deleting account data removes hosted profiles, tombstones, changes, and receipts, then rotates the epoch so a stale device cannot silently restore them. +Local profiles and capped run history remain until the user removes them or chooses local-data removal during sign-out/account removal. Hosted mutation receipts remain eligible for idempotent replay for 90 days. Expired receipts are eligible for cleanup after 90 days and are purged opportunistically on a later sync. Deleting account data removes hosted profiles, tombstones, changes, and receipts, then rotates the epoch so a stale device cannot silently restore them. Production logs are metadata-only. Operators must not log bearer tokens, request bodies, profile fields, or absolute paths. Backups containing hosted profiles must be encrypted and governed by the deployment's retention policy. diff --git a/docs/release.md b/docs/release.md index df2c5b7..b5e45a6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -23,19 +23,19 @@ pnpm test:release ## npm -`release-npm.yml` runs only from the existing `v2.0.0` tag (with an additional exact confirmation for manual runs), checks the package version, and stops if `NPM_TOKEN` is missing. An unprivileged job reruns quality/unit gates, installs the packed CLI in isolation, and uploads the exact tarball plus an independent checksum output. A minimal protected `npm-production` job verifies that checksum and packed identity, then exposes `NPM_TOKEN` only to `npm publish` with provenance and public access. +`release-npm.yml` publishes only the public `folder-structure-sync` package. It runs only from the existing `v2.0.0` tag (with an additional exact confirmation for manual runs), checks the package version, and stops if the protected `npm-production` environment secret `NPM_TOKEN` is missing. An unprivileged job reruns quality/unit gates, installs the packed CLI in isolation, and uploads the exact tarball plus an independent checksum output. A minimal protected `npm-production` job verifies that checksum and packed identity, then exposes `NPM_TOKEN` only to `npm publish` with provenance and public access. `@rootline/core` and `@rootline/contracts` remain private workspace packages bundled into the CLI; they are never published independently. -Required external gate: repository secret `NPM_TOKEN` with publish access and approval for the `npm-production` environment. +Required external gate: protected `npm-production` environment secret `NPM_TOKEN` with publish access, plus approval for that environment. Never configure this credential as a repository secret. ## API -`release-api.yml` requires the existing `v2.0.0` tag, an exact manual confirmation, and every production database/auth/deployment secret. It uses a digest-pinned Node base and refuses to run if GHCR tag `rootline-api:2.0.0` already exists. It pushes a unique candidate tag, deploys by the resulting immutable digest, applies the checked-in migrations, calls the HTTPS deployment webhook with the digest and unique workflow build identity, then polls HTTPS `/healthz` until that exact identity is live. Only after health succeeds does it promote that verified digest to `rootline-api:2.0.0`; a failed attempt leaves stable identity unused and safely retryable. A healthy response from an older replica cannot pass the gate. +`release-api.yml` requires the existing `v2.0.0` tag, an exact manual confirmation, and every production database/auth/deployment secret. It uses a digest-pinned Node base and refuses to run if GHCR tag `rootline-api:2.0.0` already exists. It pushes a unique candidate tag, applies the checked-in migrations, then calls the HTTPS deployment webhook with the resulting immutable digest and unique workflow build identity. It polls HTTPS `/healthz` until that exact identity is live. Only after health succeeds does it promote that verified digest to `rootline-api:2.0.0`; a failed attempt leaves stable identity unused and safely retryable. A healthy response from an older replica cannot pass the gate. Required external gates: `ROOTLINE_API_DATABASE_URL`, `ROOTLINE_API_DEPLOY_WEBHOOK_URL`, `ROOTLINE_API_DEPLOY_TOKEN`, `ROOTLINE_API_BASE_URL`, `ROOTLINE_JWT_ISSUER`, `ROOTLINE_JWT_AUDIENCE`, `ROOTLINE_JWT_JWKS_B64`, GHCR permissions, and `api-production` approval. PostgreSQL TLS/backup and provider runtime configuration are operator responsibilities. ## Desktop -`release-desktop.yml` runs only from the existing `v2.0.0` tag. macOS Universal is Developer ID signed, notarized, and stapled. Windows x64 and ARM64 installer/updater executables are Authenticode signed and timestamped. Tauri updater artifacts cover the default `darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, and `windows-aarch64` runtime keys; `latest.json` is generated from their non-empty `.sig` files. Release publication occurs only after code-signature/staple verification succeeds. +`release-desktop.yml` runs only from the existing `v2.0.0` tag. Before any platform build, preflight signs a challenge with the configured Tauri updater private key/password and verifies it with the configured public key; an invalid or mismatched keypair blocks the release. macOS Universal is Developer ID signed, notarized, and stapled. Windows x64 and ARM64 installer/updater executables are Authenticode signed and timestamped. Tauri updater artifacts cover the default `darwin-aarch64`, `darwin-x86_64`, `windows-x86_64`, and `windows-aarch64` runtime keys; `latest.json` is generated from their non-empty `.sig` files. Release publication occurs only after code-signature/staple verification succeeds. Required external gates: diff --git a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md index 87e4276..7bc81d7 100644 --- a/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md +++ b/docs/superpowers/plans/2026-08-15-rootline-desktop-cli-v2.md @@ -11,7 +11,7 @@ ## Global Constraints - Product display name is `Rootline by baole.space`; Tauri identifier is `space.baole.rootline`. -- Keep npm package `folder-structure-sync` and binary `folder-sync`; release all workspace packages as product version `2.0.0` where public. +- Keep npm package `folder-structure-sync` and binary `folder-sync`; it is the only public npm package. Keep private workspace packages version-aligned at `2.0.0` without publishing them. - Sync is one-way source to target and additive only: create missing directories, never copy/delete files or directories. - Source and target may not be equal, ancestors, or descendants. Skip symlinks and Windows junctions. - Node minimum is 20. CLI keeps `--dry-run`, `--verbose`, and `--auto`, and adds `--config` and `--json`. @@ -26,7 +26,7 @@ ### Task 1: Recover the Published Baseline and Establish Workspace Contracts -Recover the exact npm `1.1.0` tarball, verify its registry integrity, document its unreachable original git head, and preserve the `.ignore` directory-pruning behavior without rewriting history. Convert the repository into a pnpm workspace with root orchestration, shared TypeScript/Vitest configuration, `packages/contracts`, and skeleton packages/apps. Define and test the public domain/cloud types and shared error codes. Do not implement filesystem algorithms, UI behavior, database persistence, or API endpoints yet. +Recover the exact npm `1.1.0` tarball, verify its registry integrity, document its unreachable original git head, and preserve the `.ignore` directory-pruning behavior without rewriting history. Convert the repository into a pnpm workspace with root orchestration, shared TypeScript/Vitest configuration, `packages/contracts`, and skeleton packages/apps. Define and test the exported workspace domain/cloud types and shared error codes. Do not implement filesystem algorithms, UI behavior, database persistence, or API endpoints yet. Acceptance: frozen pnpm install succeeds; contracts tests, typecheck, and package builds pass; npm `1.1.0` recovery evidence is checked into docs; old root implementation remains available as migration evidence but is no longer the future package entry point. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 4131838..180409c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,7 +1,8 @@ { "name": "@rootline/contracts", "version": "2.0.0", - "description": "Public domain and cloud contracts for Rootline by baole.space", + "private": true, + "description": "Internal exported domain and cloud contracts for Rootline by baole.space", "license": "ISC", "type": "module", "exports": { diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index 5dd5a25..b9aef54 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -29,6 +29,16 @@ test("all workflows are valid YAML", () => { } }); +test("every third-party action is pinned to a full immutable commit SHA", () => { + for (const name of workflows) { + const actionReferences = [...workflow(name).matchAll(/uses:\s*([\w.-]+\/[\w.-]+)@([^\s#]+)/g)]; + assert.ok(actionReferences.length > 0, `${name} must use at least one pinned action`); + for (const [, action, reference] of actionReferences) { + assert.match(reference, /^[a-f0-9]{40}$/, `${name}: ${action} must use a full 40-character commit SHA`); + } + } +}); + test("CI covers TypeScript quality, real PostgreSQL, Rust, npm smoke, and the supported Tauri targets", () => { const ci = workflow("ci.yml"); for (const expected of [ @@ -67,6 +77,11 @@ test("npm 2.0.0 release fails closed before provenance publishing", () => { assert.match(release, /needs:\s*preflight/); assert.match(release, /if \[ "\$\{GITHUB_REF\}" != "refs\/tags\/v2\.0\.0" \]/); assert.equal(parsedWorkflow("release-npm.yml").jobs.preflight.environment, "npm-production"); + assert.match(release, /protected npm-production environment secret/); + assert.doesNotMatch(release, /repository secret/); + const releaseDocs = readFileSync(join(root, "docs", "release.md"), "utf8"); + assert.match(releaseDocs, /protected `npm-production` environment secret `NPM_TOKEN`/); + assert.match(releaseDocs, /Never configure this credential as a repository secret/); assert.deepEqual(jobScopedSecrets("release-npm.yml"), []); const npmJobs = parsedWorkflow("release-npm.yml").jobs; assert.equal(npmJobs.pack.permissions["id-token"], undefined); @@ -85,6 +100,17 @@ test("npm 2.0.0 release fails closed before provenance publishing", () => { assert.equal(manifest.engines.node, ">=20"); }); +test("only the bundled CLI is a public npm workspace package", () => { + const contracts = JSON.parse(readFileSync(join(root, "packages", "contracts", "package.json"), "utf8")); + const core = JSON.parse(readFileSync(join(root, "packages", "core", "package.json"), "utf8")); + const cli = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf8")); + + assert.equal(contracts.private, true); + assert.equal(core.private, true); + assert.notEqual(cli.private, true); + assert.match(contracts.description, /Internal/); +}); + test("API release gates registry, migration, deployment, and HTTPS health secrets", () => { const release = workflow("release-api.yml"); for (const expected of [ @@ -141,8 +167,18 @@ test("desktop release requires platform signing and updater signing for every st assert.doesNotMatch(release, /nsis\.zip/); assert.match(release, /setup\.exe\.sig/); assert.match(release, /needs:\s*preflight/); - assert.equal(parsedWorkflow("release-desktop.yml").jobs.preflight.environment, "desktop-production"); + const desktopJobs = parsedWorkflow("release-desktop.yml").jobs; + assert.equal(desktopJobs.preflight.environment, "desktop-production"); + assert.equal(desktopJobs["macos-universal"].needs, "preflight"); + assert.equal(desktopJobs.windows.needs, "preflight"); + assert.deepEqual(desktopJobs["publish-release"].needs, ["preflight", "macos-universal", "windows"]); assert.deepEqual(jobScopedSecrets("release-desktop.yml"), []); + const preflight = JSON.stringify(parsedWorkflow("release-desktop.yml").jobs.preflight.steps); + assert.match(preflight, /tauri signer sign/); + assert.match(preflight, /minisign -Vm/); + assert.match(preflight, /base64 --decode/); + assert.match(preflight, /private key, password, and public key do not form one updater keypair/); + assert.doesNotMatch(preflight, /continue-on-error/); }); test("desktop updater is registered and serves every default runtime target", () => { @@ -163,3 +199,14 @@ test("desktop updater is registered and serves every default runtime target", () assert.match(manifest, new RegExp(`\\"${target}\\"`)); } }); + +test("release and privacy docs describe the real ordering and lazy receipt cleanup", () => { + const release = readFileSync(join(root, "docs", "release.md"), "utf8"); + const privacy = readFileSync(join(root, "docs", "privacy.md"), "utf8"); + const migrationIndex = release.indexOf("applies the checked-in migrations"); + const deploymentIndex = release.indexOf("calls the HTTPS deployment webhook"); + + assert.ok(migrationIndex >= 0 && deploymentIndex >= 0 && migrationIndex < deploymentIndex); + assert.match(privacy, /eligible for cleanup after 90 days/i); + assert.match(privacy, /opportunistically on a later sync/i); +}); From d5e9ff754948ae1446ef27340926476aef26dfb2 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 10:44:29 +0700 Subject: [PATCH 17/26] fix: resolve Rootline v2 final review findings --- .github/workflows/ci.yml | 1 + .github/workflows/release-api.yml | 7 + .github/workflows/release-desktop.yml | 1 + .github/workflows/release-npm.yml | 1 + apps/api/package.json | 1 + .../migration.sql | 17 + apps/api/prisma/schema.prisma | 14 + apps/api/src/sync.dto.ts | 14 +- apps/api/src/sync.service.ts | 6 +- apps/api/src/testing.ts | 1 + apps/api/test/sync.e2e.spec.ts | 90 +++ apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 2 + apps/desktop/src-tauri/Cargo.toml | 6 + .../0006_invalid_outbox_quarantine.sql | 8 + apps/desktop/src-tauri/src/lib.rs | 640 +++++++++++++++++- apps/desktop/src-tauri/tests/native.rs | 316 +++++++++ apps/desktop/src/App.tsx | 136 +++- apps/desktop/src/auth.ts | 91 ++- apps/desktop/src/components/AuthControls.tsx | 129 +++- apps/desktop/src/i18n.ts | 93 +++ apps/desktop/src/styles.css | 8 +- apps/desktop/src/test/App.test.tsx | 76 ++- apps/desktop/src/test/AuthControls.test.tsx | 69 ++ apps/desktop/src/test/auth.test.ts | 87 +++ docs/configuration.md | 2 +- docs/hosted-profile-sync.md | 8 +- docs/privacy.md | 2 +- package.json | 6 + packages/contracts/src/index.ts | 43 ++ packages/contracts/src/profile-limits.json | 5 + packages/contracts/test/contracts.test.ts | 32 + packages/contracts/tsconfig.json | 3 +- pnpm-lock.yaml | 17 +- tests/release-workflows.test.mjs | 20 + 35 files changed, 1862 insertions(+), 91 deletions(-) create mode 100644 apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql create mode 100644 apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql create mode 100644 packages/contracts/src/profile-limits.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78ec3eb..eead057 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high - run: pnpm lint - run: pnpm typecheck - run: pnpm test:unit diff --git a/.github/workflows/release-api.yml b/.github/workflows/release-api.yml index 0b761f6..4848edc 100644 --- a/.github/workflows/release-api.yml +++ b/.github/workflows/release-api.yml @@ -21,6 +21,13 @@ jobs: environment: api-production steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high - name: Require exact version and every production deployment secret env: CONFIRMATION: ${{ inputs.confirmation }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 27d43fa..be3cbd5 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -29,6 +29,7 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high - name: Install minisign for updater key-pair proof run: sudo apt-get update && sudo apt-get install --yes minisign - name: Require stable tag, release confirmation, signing credentials, and hosted profile endpoints diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 4817c77..7c838ee 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -59,6 +59,7 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high - run: pnpm lint && pnpm typecheck && pnpm test:unit - run: pnpm --filter folder-structure-sync test:pack - run: mkdir release && pnpm --filter folder-structure-sync pack --pack-destination release diff --git a/apps/api/package.json b/apps/api/package.json index 5686ce6..91d8674 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "prisma:migrate:deploy": "prisma migrate deploy" }, "dependencies": { + "@rootline/contracts": "workspace:*", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.6", diff --git a/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql b/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql new file mode 100644 index 0000000..b582b9a --- /dev/null +++ b/apps/api/prisma/migrations/20260815100000_durable_mutation_dedup/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE "mutation_dedup" ( + "subject" TEXT NOT NULL, + "mutation_id" UUID NOT NULL, + "mutation_hash" TEXT NOT NULL, + "revision" BIGINT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "mutation_dedup_pkey" PRIMARY KEY ("subject", "mutation_id") +); + +INSERT INTO "mutation_dedup" ("subject", "mutation_id", "mutation_hash", "revision", "created_at") +SELECT "subject", "mutation_id", "mutation_hash", "revision", "created_at" +FROM "mutation_receipt"; + +ALTER TABLE "mutation_dedup" +ADD CONSTRAINT "mutation_dedup_subject_fkey" +FOREIGN KEY ("subject") REFERENCES "user_sync_state"("subject") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a90b3a6..5a9553c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -17,6 +17,7 @@ model UserSyncState { profiles ProfileRecord[] changes SyncChange[] receipts MutationReceipt[] + dedupEntries MutationDedup[] @@map("user_sync_state") } @@ -63,3 +64,16 @@ model MutationReceipt { @@index([expiresAt]) @@map("mutation_receipt") } + +model MutationDedup { + subject String + mutationId String @db.Uuid @map("mutation_id") + mutationHash String @map("mutation_hash") + revision BigInt + createdAt DateTime @default(now()) @map("created_at") + + owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) + + @@id([subject, mutationId]) + @@map("mutation_dedup") +} diff --git a/apps/api/src/sync.dto.ts b/apps/api/src/sync.dto.ts index d22ea0d..524cde1 100644 --- a/apps/api/src/sync.dto.ts +++ b/apps/api/src/sync.dto.ts @@ -1,14 +1,20 @@ import { Type } from "class-transformer"; +import { PROFILE_LIMITS } from "@rootline/contracts"; import { ArrayMaxSize, IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested, } from "class-validator"; export class SyncProfileDto { @IsString() @MinLength(1) @MaxLength(128) id!: string; - @IsString() @MinLength(1) @MaxLength(120) name!: string; - @IsString() @MinLength(1) @MaxLength(4096) sourcePath!: string; - @IsString() @MinLength(1) @MaxLength(4096) targetPath!: string; - @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) @MaxLength(512, { each: true }) exclusions!: string[]; + @IsString() @MinLength(PROFILE_LIMITS.name.min) @MaxLength(PROFILE_LIMITS.name.max) name!: string; + @IsString() @MinLength(PROFILE_LIMITS.path.min) @MaxLength(PROFILE_LIMITS.path.max) sourcePath!: string; + @IsString() @MinLength(PROFILE_LIMITS.path.min) @MaxLength(PROFILE_LIMITS.path.max) targetPath!: string; + @IsArray() + @ArrayMaxSize(PROFILE_LIMITS.exclusions.max) + @IsString({ each: true }) + @MinLength(PROFILE_LIMITS.exclusions.pattern.min, { each: true }) + @MaxLength(PROFILE_LIMITS.exclusions.pattern.max, { each: true }) + exclusions!: string[]; @IsDateString() createdAt!: string; @IsDateString() updatedAt!: string; @IsOptional() @IsIn(["additive"]) syncMode?: "additive"; diff --git a/apps/api/src/sync.service.ts b/apps/api/src/sync.service.ts index 352728a..7c450bb 100644 --- a/apps/api/src/sync.service.ts +++ b/apps/api/src/sync.service.ts @@ -66,7 +66,7 @@ export class SyncService { const receipts: Array<{ mutationId: string; revision: number }> = []; for (const mutation of dto.mutations) { const hash = mutationHash(mutation); - const prior = await tx.mutationReceipt.findUnique({ where: { subject_mutationId: { subject, mutationId: mutation.mutationId } } }); + const prior = await tx.mutationDedup.findUnique({ where: { subject_mutationId: { subject, mutationId: mutation.mutationId } } }); if (prior) { if (prior.mutationHash !== hash) throw new ConflictException("A mutation ID cannot be reused for different profile data."); receipts.push({ mutationId: mutation.mutationId, revision: Number(prior.revision) }); @@ -91,6 +91,9 @@ export class SyncService { }, }); await tx.syncChange.create({ data: { subject, revision, record: json(record), committedAt } }); + await tx.mutationDedup.create({ + data: { subject, mutationId: mutation.mutationId, mutationHash: hash, revision, createdAt: committedAt }, + }); await tx.mutationReceipt.create({ data: { subject, mutationId: mutation.mutationId, mutationHash: hash, revision, expiresAt: new Date(committedAt.getTime() + RECEIPT_TTL_MS) }, }); @@ -131,6 +134,7 @@ export class SyncService { await tx.profileRecord.deleteMany({ where: { subject } }); await tx.syncChange.deleteMany({ where: { subject } }); await tx.mutationReceipt.deleteMany({ where: { subject } }); + await tx.mutationDedup.deleteMany({ where: { subject } }); await tx.userSyncState.update({ where: { subject }, data: { epoch, revision: 0n } }); return { epoch }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts index d0ad82c..d60ddf8 100644 --- a/apps/api/src/testing.ts +++ b/apps/api/src/testing.ts @@ -20,6 +20,7 @@ export async function createTestApplication(overrides: Partial { let directory: string; let privateKey: CryptoKey; let apiUrl: string; + let postgres: PrismaClient; beforeAll(async () => { if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL must point to a real PostgreSQL test database"); @@ -43,10 +45,15 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { const address = fixture.server.address(); if (!address || typeof address === "string") throw new Error("Test API did not bind a TCP port"); apiUrl = `http://127.0.0.1:${address.port}`; + postgres = new PrismaClient({ + datasources: { db: { url: process.env.DATABASE_URL } }, + }); + await postgres.$connect(); await fixture.resetDatabase(); }); afterAll(async () => { + await postgres?.$disconnect(); await fixture?.close(); if (directory) await rm(directory, { recursive: true, force: true }); }); @@ -123,6 +130,61 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { expect(isolated.body.records).toEqual([]); }); + test("keeps mutation idempotency for the account epoch after the 90-day receipt expires", async () => { + const subject = "durable-dedup-user"; + const auth = await token(subject); + const original = mutation(PROFILE_ID, "Original", "00000000-0000-4000-8000-000000000211"); + const newer = mutation(PROFILE_ID, "Newer", "00000000-0000-4000-8000-000000000212"); + + const revisionOne = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-a", epoch: EPOCH, mutations: [original] }).expect(200); + const revisionTwo = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-b", epoch: EPOCH, cursor: revisionOne.body.cursor, mutations: [newer] }).expect(200); + + await postgres.$executeRaw` + UPDATE "mutation_receipt" + SET "expires_at" = NOW() - INTERVAL '1 day' + WHERE "subject" = ${subject} AND "mutation_id" = ${original.mutationId}::uuid + `; + const replay = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "dedup-a", epoch: EPOCH, cursor: revisionTwo.body.cursor, mutations: [original] }).expect(200); + + expect(replay.body.receipts).toEqual([{ mutationId: original.mutationId, revision: 1 }]); + expect(replay.body.records).toEqual([]); + const state = await postgres.$queryRaw>` + SELECT "revision" FROM "user_sync_state" WHERE "subject" = ${subject} + `; + const profile = await postgres.$queryRaw>` + SELECT "profile" FROM "profile_record" WHERE "subject" = ${subject} AND "profile_id" = ${PROFILE_ID} + `; + const changes = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "sync_change" WHERE "subject" = ${subject} + `; + const receipts = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "mutation_receipt" + WHERE "subject" = ${subject} AND "mutation_id" = ${original.mutationId}::uuid + `; + expect(state[0]?.revision).toBe(2n); + expect(profile[0]?.profile.name).toBe("Newer"); + expect(changes[0]?.count).toBe(2n); + expect(receipts[0]?.count).toBe(0n); + + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "dedup-a", + epoch: EPOCH, + cursor: revisionTwo.body.cursor, + mutations: [{ ...original, profile: { ...original.profile, name: "Collision" } }], + }).expect(409); + + await request(fixture.server).delete("/v1/account-data").set("Authorization", `Bearer ${auth}`) + .send({ epoch: EPOCH }).expect(200); + const ledger = await postgres.$queryRaw>` + SELECT COUNT(*)::bigint AS "count" FROM "mutation_dedup" WHERE "subject" = ${subject} + `; + expect(ledger[0]?.count).toBe(0n); + }); + test("returns cursor deltas and tombstones without resurrecting profiles", async () => { const auth = await token("delta-user"); const upsert = mutation(PROFILE_ID, "Delta", "00000000-0000-4000-8000-000000000301"); @@ -213,6 +275,34 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { .send({ deviceId: "offline-reader", epoch: EPOCH, cursor: "not-a-cursor", mutations: [] }).expect(400); }); + test("enforces the shared profile limits at their exact boundaries", async () => { + const auth = await token("profile-limit-user"); + const boundary = mutation(PROFILE_ID, "n".repeat(80), "00000000-0000-4000-8000-000000000521"); + boundary.profile.sourcePath = "s".repeat(4096); + boundary.profile.targetPath = "t".repeat(4096); + boundary.profile.exclusions = Array.from({ length: 100 }, () => "x".repeat(256)); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "profile-limits", epoch: EPOCH, mutations: [boundary] }).expect(200); + + const invalidProfiles = [ + { ...boundary.profile, name: "n".repeat(81) }, + { ...boundary.profile, sourcePath: "" }, + { ...boundary.profile, targetPath: "t".repeat(4097) }, + { ...boundary.profile, exclusions: Array.from({ length: 101 }, () => "x") }, + { ...boundary.profile, exclusions: [""] }, + { ...boundary.profile, exclusions: ["x".repeat(257)] }, + ]; + for (const [index, profile] of invalidProfiles.entries()) { + const invalid = { + ...boundary, + mutationId: `00000000-0000-4000-8000-${String(522 + index).padStart(12, "0")}`, + profile, + }; + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ deviceId: "profile-limits", epoch: EPOCH, mutations: [invalid] }).expect(400); + } + }); + test("enforces DTO, body, and per-user request limits", async () => { const auth = await token("limits-user"); const invalid = mutation(PROFILE_ID, "x".repeat(121), "00000000-0000-4000-8000-000000000501"); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 75ec2ab..d227e56 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "tauri": "tauri" }, "dependencies": { + "@rootline/contracts": "workspace:*", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-deep-link": "^2.4.3", "@tauri-apps/plugin-opener": "^2.5.0", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2dcc62f..031e085 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4115,6 +4115,7 @@ name = "rootline-desktop" version = "2.0.0" dependencies = [ "keyring", + "libc", "rand 0.9.5", "reqwest 0.12.28", "rfd", @@ -4134,6 +4135,7 @@ dependencies = [ "tokio", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9f6f2a0..3c5aa63 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -39,3 +39,9 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem"] } diff --git a/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql b/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql new file mode 100644 index 0000000..27f7336 --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0006_invalid_outbox_quarantine.sql @@ -0,0 +1,8 @@ +CREATE TABLE mutation_quarantine ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + mutation_id TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + profile_id TEXT NOT NULL, + reason TEXT NOT NULL, + quarantined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0831817..1620bdf 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,11 +1,10 @@ use std::{ collections::{HashMap, HashSet}, fs, - io::Write, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Mutex, + Arc, Mutex, OnceLock, }, time::{SystemTime, UNIX_EPOCH}, }; @@ -34,6 +33,7 @@ pub enum NativeErrorCode { SyncEpochResetRequired, SyncAccountClaimRequired, SyncStateChanged, + ValidationFailed, Internal, } @@ -242,7 +242,7 @@ fn absolute(path: &Path) -> Result { } fn canonical_directory(path: &Path, role: &str) -> Result { - assert_not_link(path)?; + assert_no_link_ancestors(path)?; let canonical = fs::canonicalize(path).map_err(|error| { let code = if error.kind() == std::io::ErrorKind::NotFound { if role == "source" { @@ -265,6 +265,56 @@ fn canonical_directory(path: &Path, role: &str) -> Result Ok(canonical) } +fn assert_no_link_ancestors(path: &Path) -> Result<(), NativeError> { + let absolute = absolute(path)?; + let mut current = PathBuf::new(); + for component in absolute.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) + if is_link_or_junction(&metadata) && !is_allowed_platform_root_alias(¤t) => + { + return Err(NativeError { + code: NativeErrorCode::InvalidPath, + message: + "A synchronization root must not traverse a symbolic link or junction." + .into(), + details: Some(json!({ "path": absolute, "linkedAncestor": current })), + }); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn is_allowed_platform_root_alias(path: &Path) -> bool { + let expected = if path == Path::new("/var") { + Some(Path::new("/private/var")) + } else if path == Path::new("/tmp") { + Some(Path::new("/private/tmp")) + } else if path == Path::new("/etc") { + Some(Path::new("/private/etc")) + } else { + None + }; + expected.is_some_and(|expected| fs::canonicalize(path).is_ok_and(|actual| actual == expected)) +} + +#[cfg(not(target_os = "macos"))] +fn is_allowed_platform_root_alias(_path: &Path) -> bool { + false +} + fn assert_not_link(path: &Path) -> Result<(), NativeError> { let absolute = absolute(path)?; match fs::symlink_metadata(&absolute) { @@ -341,30 +391,150 @@ fn validate_relationship( Ok(()) } +fn nearest_existing_ancestor(path: &Path) -> Result { + let mut current = absolute(path)?; + loop { + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => return Ok(current), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + } + if !current.pop() { + return Err(NativeError::at( + NativeErrorCode::InvalidPath, + "The path has no existing directory ancestor.", + path, + )); + } + } +} + +#[cfg(target_os = "macos")] +fn case_sensitive_from_os(directory: &Path) -> Result { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + #[repr(C)] + struct VolumeCapabilitiesBuffer { + length: u32, + capabilities: [u32; 4], + valid: [u32; 4], + } + + let path = CString::new(directory.as_os_str().as_bytes()).map_err(|_| { + NativeError::at( + NativeErrorCode::InvalidPath, + "The target path contains a null byte.", + directory, + ) + })?; + let mut attributes = libc::attrlist { + bitmapcount: libc::ATTR_BIT_MAP_COUNT, + reserved: 0, + commonattr: 0, + volattr: libc::ATTR_VOL_CAPABILITIES, + dirattr: 0, + fileattr: 0, + forkattr: 0, + }; + let mut buffer = VolumeCapabilitiesBuffer { + length: 0, + capabilities: [0; 4], + valid: [0; 4], + }; + // SAFETY: `path`, `attributes`, and `buffer` remain valid for this synchronous + // call, and the buffer exactly matches the requested fixed-size volume attribute. + let result = unsafe { + libc::getattrlist( + path.as_ptr(), + (&mut attributes as *mut libc::attrlist).cast(), + (&mut buffer as *mut VolumeCapabilitiesBuffer).cast(), + std::mem::size_of::(), + 0, + ) + }; + if result != 0 { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + let capability = libc::VOL_CAP_FMT_CASE_SENSITIVE; + Ok(buffer.valid[0] & capability != 0 && buffer.capabilities[0] & capability != 0) +} + +#[cfg(windows)] +fn case_sensitive_from_os(directory: &Path) -> Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + CreateFileW, FileCaseSensitiveInfo, GetFileInformationByHandleEx, + FILE_CASE_SENSITIVE_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }, + }; + + let wide = directory + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: the UTF-16 path is null-terminated and all other arguments are constants/null. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + let mut information = FILE_CASE_SENSITIVE_INFO::default(); + // SAFETY: `handle` is live and `information` is the exact structure requested. + let result = unsafe { + GetFileInformationByHandleEx( + handle, + FileCaseSensitiveInfo, + (&mut information as *mut FILE_CASE_SENSITIVE_INFO).cast(), + std::mem::size_of::() as u32, + ) + }; + // SAFETY: the handle came from CreateFileW and is closed exactly once here. + unsafe { CloseHandle(handle) }; + if result == 0 { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + std::io::Error::last_os_error().to_string(), + directory, + )); + } + Ok(information.Flags & 1 != 0) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn case_sensitive_from_os(_directory: &Path) -> Result { + Ok(true) +} + pub fn detect_case_sensitive(directory: &Path) -> Result { - let probe_name = format!(".rootline-case-probe-{}", Uuid::new_v4().simple()); - let probe = directory.join(&probe_name); - let alternate = directory.join(probe_name.to_uppercase()); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&probe) - .map_err(|error| { - NativeError::at( - NativeErrorCode::UnreadablePath, - error.to_string(), - directory, - ) - })?; - let outcome = (|| { - file.write_all(b"rootline").map_err(|error| { - NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), &probe) - })?; - Ok(!alternate.exists()) - })(); - drop(file); - let _ = fs::remove_file(probe); - outcome + case_sensitive_from_os(&nearest_existing_ancestor(directory)?) } fn normalize_relative(path: &Path) -> String { @@ -478,6 +648,7 @@ pub fn scan_plan( token.check()?; let source = canonical_directory(&request.source_path, "source")?; let target = canonical_directory(&request.target_path, "target")?; + validate_relationship(&source, &target, !cfg!(any(target_os = "macos", windows)))?; let target_case_sensitive = detect_case_sensitive(&target)?; validate_relationship(&source, &target, target_case_sensitive)?; let source_snapshot = scan_root(&source, &request.exclusions, token)?; @@ -663,6 +834,180 @@ pub struct Profile { pub updated_at: String, } +#[derive(Debug, Deserialize)] +struct CharacterLimits { + min: usize, + max: usize, +} + +#[derive(Debug, Deserialize)] +struct ExclusionLimits { + max: usize, + pattern: CharacterLimits, +} + +#[derive(Debug, Deserialize)] +struct ProfileLimits { + name: CharacterLimits, + path: CharacterLimits, + exclusions: ExclusionLimits, +} + +fn profile_limits() -> &'static ProfileLimits { + static LIMITS: OnceLock = OnceLock::new(); + LIMITS.get_or_init(|| { + serde_json::from_str(include_str!( + "../../../../packages/contracts/src/profile-limits.json" + )) + .expect("shared profile limits must be valid JSON") + }) +} + +fn profile_validation_error(field: &str, min: Option, max: usize) -> NativeError { + NativeError { + code: NativeErrorCode::ValidationFailed, + message: "The profile exceeds Rootline's hosted profile limits.".into(), + details: Some(json!({ "field": field, "min": min, "max": max })), + } +} + +fn validate_profile(profile: &Profile) -> Result<(), NativeError> { + let id_length = profile.id.chars().count(); + if !(1..=128).contains(&id_length) { + return Err(profile_validation_error("id", Some(1), 128)); + } + let limits = profile_limits(); + for (field, value, limit) in [ + ("name", profile.name.as_str(), &limits.name), + ("sourcePath", profile.source_path.as_str(), &limits.path), + ("targetPath", profile.target_path.as_str(), &limits.path), + ] { + let length = value.chars().count(); + if length < limit.min || length > limit.max { + return Err(profile_validation_error(field, Some(limit.min), limit.max)); + } + } + if profile.exclusions.len() > limits.exclusions.max { + return Err(profile_validation_error( + "exclusions", + None, + limits.exclusions.max, + )); + } + for (index, pattern) in profile.exclusions.iter().enumerate() { + let length = pattern.chars().count(); + let limit = &limits.exclusions.pattern; + if length < limit.min || length > limit.max { + return Err(profile_validation_error( + &format!("exclusions[{index}]"), + Some(limit.min), + limit.max, + )); + } + } + Ok(()) +} + +fn outbox_validation_error( + mutation_id: &str, + kind: &str, + payload: &str, + occurred_at: &str, +) -> Option { + if Uuid::parse_str(mutation_id).is_err() { + return Some("mutationId is not a UUID".into()); + } + if OffsetDateTime::parse(occurred_at, &Rfc3339).is_err() { + return Some("occurredAt is not an RFC 3339 timestamp".into()); + } + match kind { + "upsert" => { + let profile: Profile = match serde_json::from_str(payload) { + Ok(profile) => profile, + Err(_) => return Some("profile payload is invalid JSON".into()), + }; + if OffsetDateTime::parse(&profile.created_at, &Rfc3339).is_err() + || OffsetDateTime::parse(&profile.updated_at, &Rfc3339).is_err() + { + return Some("profile timestamps are invalid".into()); + } + validate_profile(&profile).err().map(|error| { + let field = error + .details + .as_ref() + .and_then(|details| details.get("field")) + .and_then(serde_json::Value::as_str) + .unwrap_or("profile"); + format!("profile {field} exceeds the hosted limit") + }) + } + "delete" => { + let profile_id = serde_json::from_str::(payload) + .ok() + .and_then(|value| { + value + .get("profileId") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }); + match profile_id { + Some(profile_id) if (1..=128).contains(&profile_id.chars().count()) => None, + _ => Some("delete profileId is invalid".into()), + } + } + _ => Some("mutation kind is invalid".into()), + } +} + +fn quarantine_invalid_outbox(connection: &mut Connection) -> Result { + let candidates = { + let mut statement = connection.prepare( + "SELECT sequence, mutation_id, kind, payload, occurred_at, profile_id + FROM mutation_outbox ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + })?; + rows.collect::, _>>()? + }; + let transaction = connection.transaction()?; + let mut quarantined = 0; + for (sequence, mutation_id, kind, payload, occurred_at, profile_id) in candidates { + let Some(reason) = outbox_validation_error(&mutation_id, &kind, &payload, &occurred_at) + else { + continue; + }; + transaction.execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(mutation_id) DO UPDATE SET kind=excluded.kind, + profile_id=excluded.profile_id, reason=excluded.reason, + quarantined_at=CURRENT_TIMESTAMP", + params![mutation_id, kind, profile_id, reason], + )?; + transaction.execute( + "DELETE FROM mutation_outbox WHERE sequence = ?1", + [sequence], + )?; + quarantined += 1; + } + if quarantined > 0 { + transaction.execute( + "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", + [], + )?; + } + transaction.commit()?; + Ok(quarantined) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct OutboxMutation { @@ -672,6 +1017,14 @@ pub struct OutboxMutation { pub occurred_at: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuarantinedMutation { + pub mutation_id: String, + pub profile_id: String, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct RunRecord { @@ -702,6 +1055,10 @@ const MIGRATIONS: &[(i64, &str)] = &[ 5, include_str!("../migrations/0005_sync_lifecycle_generation.sql"), ), + ( + 6, + include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), + ), ]; const HOSTED_SYNC_MUTATION_LIMIT: usize = 100; const HOSTED_SYNC_BODY_LIMIT: usize = 256 * 1024; @@ -731,6 +1088,7 @@ impl Database { )?; transaction.commit()?; } + quarantine_invalid_outbox(&mut connection)?; Ok(Self(Mutex::new(connection))) } @@ -759,6 +1117,7 @@ impl Database { } pub fn save_profile(&self, profile: &Profile) -> Result<(), NativeError> { + validate_profile(profile)?; let payload = serde_json::to_string(profile) .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; let mut connection = self.connection(); @@ -772,6 +1131,10 @@ impl Database { serde_json::to_string(&profile.exclusions).map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?, profile.created_at, profile.updated_at], )?; + transaction.execute( + "DELETE FROM mutation_quarantine WHERE profile_id = ?1", + [&profile.id], + )?; transaction.execute( "INSERT INTO mutation_outbox( mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt @@ -819,6 +1182,10 @@ impl Database { let mut connection = self.connection(); let transaction = connection.transaction()?; transaction.execute("DELETE FROM profiles WHERE id = ?1", [id])?; + transaction.execute( + "DELETE FROM mutation_quarantine WHERE profile_id = ?1", + [id], + )?; transaction.execute( "INSERT INTO mutation_outbox( mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt @@ -883,6 +1250,22 @@ impl Database { Ok(rows.collect::, _>>()?) } + pub fn quarantined_mutations(&self) -> Result, NativeError> { + let connection = self.connection(); + let mut statement = connection.prepare( + "SELECT mutation_id, profile_id, reason + FROM mutation_quarantine ORDER BY sequence ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(QuarantinedMutation { + mutation_id: row.get(0)?, + profile_id: row.get(1)?, + reason: row.get(2)?, + }) + })?; + Ok(rows.collect::, _>>()?) + } + pub fn acknowledge_mutations(&self, ids: &[String]) -> Result<(), NativeError> { let mut connection = self.connection(); let transaction = connection.transaction()?; @@ -932,6 +1315,7 @@ impl Database { let transaction = connection.transaction()?; transaction.execute("DELETE FROM profiles", [])?; transaction.execute("DELETE FROM mutation_outbox", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; transaction.execute("DELETE FROM sync_state", [])?; transaction.commit()?; Ok(()) @@ -956,6 +1340,7 @@ impl Database { )?; if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; } transaction.commit()?; Ok(()) @@ -1050,6 +1435,7 @@ impl Database { } if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; } let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( @@ -1120,6 +1506,7 @@ impl Database { transaction.execute("DELETE FROM mutation_outbox", [])?; if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; + transaction.execute("DELETE FROM mutation_quarantine", [])?; } let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( @@ -1144,7 +1531,8 @@ impl Database { subject: &str, expected_lifecycle_generation: Option<&str>, ) -> Result<(serde_json::Value, String), NativeError> { - let connection = self.connection(); + let mut connection = self.connection(); + quarantine_invalid_outbox(&mut connection)?; let device_id: String = match connection .query_row( "SELECT value FROM settings WHERE key='device_id'", @@ -1455,6 +1843,7 @@ impl Database { })?, ) .map_err(internal_error)?; + validate_profile(&profile)?; let has_pending_local_mutation: i64 = transaction.query_row( "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", [&profile.id], @@ -1668,12 +2057,53 @@ fn delete_profile(id: String, database: State<'_, Database>) -> Result<(), Nativ struct HostedSyncOutcome { acknowledged: usize, records_applied: usize, + quarantined_mutations: usize, cursor: String, } #[derive(Default)] struct HostedSyncLock(tokio::sync::Mutex<()>); +fn validate_exact_receipt_ids( + response: &serde_json::Value, + sent_ids: &HashSet, +) -> Result { + let receipts = response + .get("receipts") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned invalid mutation receipts; local data remains queued.", + ) + })?; + let mut received_ids = HashSet::with_capacity(receipts.len()); + for receipt in receipts { + let mutation_id = receipt + .get("mutationId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned an invalid mutation receipt; local data remains queued.", + ) + })?; + if !received_ids.insert(mutation_id.to_owned()) { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync returned duplicate mutation receipts; local data remains queued.", + )); + } + } + if &received_ids != sent_ids { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Hosted sync receipts did not exactly match the sent mutations; local data remains queued.", + )); + } + Ok(receipts.len()) +} + async fn run_hosted_sync_loop( database: &Database, subject: &str, @@ -1749,6 +2179,7 @@ where "Hosted sync was rejected; local data remains safe.", )); } + let receipt_count = validate_exact_receipt_ids(&body, &sent_ids)?; let response_cursor = body .get("cursor") .and_then(serde_json::Value::as_str) @@ -1775,10 +2206,7 @@ where } Err(error) => return Err(error), } - acknowledged += body - .get("receipts") - .and_then(serde_json::Value::as_array) - .map_or(0, Vec::len); + acknowledged += receipt_count; records_applied += body .get("records") .and_then(serde_json::Value::as_array) @@ -1812,6 +2240,7 @@ where Ok(HostedSyncOutcome { acknowledged, records_applied, + quarantined_mutations: database.quarantined_mutations()?.len(), cursor, }) } @@ -2455,4 +2884,153 @@ mod tests { assert!(database.pending_outbox().unwrap().is_empty()); }); } + + fn assert_invalid_receipt_set_preserves_outbox(fault: &str) { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = Database::open( + directory + .path() + .join(format!("invalid-{fault}-receipts.sqlite3")), + ) + .unwrap(); + for index in 0..101 { + database + .enqueue_mutation( + &format!("00000000-0000-4000-8006-{index:012}"), + "upsert", + &json!({ + "id": format!("profile-{index}"), + "name": format!("Profile {index}"), + "sourcePath": format!("/source/{index}"), + "targetPath": format!("/target/{index}"), + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + }) + .to_string(), + "2026-08-15T00:00:00Z", + ) + .unwrap(); + } + database.claim_hosted_account("alice", true).unwrap(); + let before = database.pending_outbox().unwrap(); + let before_cursor = database.sync_cursor().unwrap(); + let sends = Arc::new(AtomicUsize::new(0)); + let counted_sends = Arc::clone(&sends); + let fault = fault.to_owned(); + + let error = run_hosted_sync_loop(&database, "alice", move |payload| { + let attempt = counted_sends.fetch_add(1, Ordering::SeqCst); + let fault = fault.clone(); + async move { + if attempt > 0 { + return Err(NativeError::new( + NativeErrorCode::Internal, + "Transport was reused after an invalid receipt set.", + )); + } + let mutations = payload["mutations"].as_array().unwrap(); + assert_eq!(mutations.len(), 100); + let mut receipts = mutations + .iter() + .enumerate() + .map(|(index, mutation)| { + json!({ "mutationId": mutation["mutationId"], "revision": index + 1 }) + }) + .collect::>(); + match fault.as_str() { + "missing" => { + receipts.pop(); + } + "duplicate" => receipts.push(receipts[0].clone()), + "extra-101" => receipts.push(json!({ + "mutationId": "00000000-0000-4000-8006-000000000100", + "revision": 101 + })), + _ => unreachable!(), + } + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "must-not-commit", + "hasMore": false, + "records": [], + "receipts": receipts + }), + )) + } + }) + .await + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::Internal); + assert_eq!(sends.load(Ordering::SeqCst), 1); + assert_eq!(database.pending_outbox().unwrap(), before); + assert_eq!(database.sync_cursor().unwrap(), before_cursor); + }); + } + + #[test] + fn command_loop_rejects_missing_receipts_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("missing"); + } + + #[test] + fn command_loop_rejects_duplicate_receipts_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("duplicate"); + } + + #[test] + fn command_loop_rejects_an_extra_101st_receipt_before_mutating_sqlite() { + assert_invalid_receipt_set_preserves_outbox("extra-101"); + } + + #[test] + fn command_loop_reports_quarantined_legacy_mutations_without_fifo_wedging() { + tauri::async_runtime::block_on(async { + let directory = tempdir().unwrap(); + let database = + Database::open(directory.path().join("quarantine-status.sqlite3")).unwrap(); + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000010", + "upsert", + &json!({ + "id": "legacy-invalid", + "name": "n".repeat(81), + "sourcePath": "/source", + "targetPath": "/target", + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + }) + .to_string(), + "2026-08-15T00:00:00Z", + ) + .unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + + let outcome = run_hosted_sync_loop(&database, "alice", |payload| async move { + assert_eq!(payload["mutations"], json!([])); + Ok(( + reqwest::StatusCode::OK, + json!({ + "epoch": payload["epoch"], + "cursor": "quarantine-observed", + "hasMore": false, + "records": [], + "receipts": [] + }), + )) + }) + .await + .unwrap(); + + assert_eq!(outcome.quarantined_mutations, 1); + assert!(database.pending_outbox().unwrap().is_empty()); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + }); + } } diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index 73da4ab..5be65c1 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -127,6 +127,63 @@ fn rejects_overlapping_roots_and_honors_cancellation() { ); } +#[cfg(unix)] +#[test] +fn case_detection_is_read_only_and_overlap_wins_before_target_inspection() { + use std::os::unix::fs::PermissionsExt; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir(target.path().join("existing-directory")).unwrap(); + fs::write(target.path().join("keep.bin"), [0, 1, 2, 255]).unwrap(); + let before_entries = fs::read_dir(target.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + let before_bytes = fs::read(target.path().join("keep.bin")).unwrap(); + let original_mode = fs::metadata(target.path()).unwrap().permissions().mode(); + fs::set_permissions(target.path(), fs::Permissions::from_mode(0o555)).unwrap(); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert_eq!(plan.missing, ["docs", "docs/api"]); + + fs::set_permissions(target.path(), fs::Permissions::from_mode(original_mode)).unwrap(); + let after_entries = fs::read_dir(target.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(after_entries, before_entries); + assert_eq!( + fs::read(target.path().join("keep.bin")).unwrap(), + before_bytes + ); + + let nested_target = source.path().join("nested-target"); + fs::create_dir(&nested_target).unwrap(); + fs::write(nested_target.join("sentinel"), b"unchanged").unwrap(); + fs::set_permissions(&nested_target, fs::Permissions::from_mode(0o555)).unwrap(); + let error = scan_plan( + &request(source.path(), &nested_target), + &CancellationToken::default(), + ) + .unwrap_err(); + fs::set_permissions(&nested_target, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(error.code, NativeErrorCode::PathOverlap); + assert_eq!( + fs::read(nested_target.join("sentinel")).unwrap(), + b"unchanged" + ); + + let implementation = include_str!("../src/lib.rs"); + assert!(!implementation.contains(".rootline-case-probe")); + assert!(!implementation.contains("create_new(true)")); +} + #[cfg(unix)] #[test] fn skips_symbolic_links_instead_of_following_them() { @@ -157,6 +214,21 @@ fn skips_symbolic_links_instead_of_following_them() { .code, NativeErrorCode::InvalidPath, ); + + let parent = tempdir().unwrap(); + let real_ancestor = tempdir().unwrap(); + fs::create_dir(real_ancestor.path().join("source")).unwrap(); + let linked_ancestor = parent.path().join("linked-ancestor"); + symlink(real_ancestor.path(), &linked_ancestor).unwrap(); + assert_eq!( + scan_plan( + &request(&linked_ancestor.join("source"), target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); } #[test] @@ -230,6 +302,188 @@ fn migrates_and_persists_offline_state_with_bounded_history() { assert_eq!(reopened.device_id().unwrap(), device_id); } +#[test] +fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("profile-limits.sqlite3")).unwrap(); + let boundary = Profile { + id: "boundary".into(), + name: "n".repeat(80), + source_path: "s".repeat(4096), + target_path: "t".repeat(4096), + exclusions: (0..100).map(|_| "x".repeat(256)).collect(), + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&boundary).unwrap(); + let before_profiles = database.list_profiles().unwrap(); + let before_outbox = database.pending_outbox().unwrap(); + + let invalid = [ + Profile { + id: "bad-name".into(), + name: "n".repeat(81), + ..boundary.clone() + }, + Profile { + id: "bad-source".into(), + source_path: String::new(), + ..boundary.clone() + }, + Profile { + id: "bad-target".into(), + target_path: "t".repeat(4097), + ..boundary.clone() + }, + Profile { + id: "bad-count".into(), + exclusions: (0..101).map(|_| "x".into()).collect(), + ..boundary.clone() + }, + Profile { + id: "bad-pattern".into(), + exclusions: vec!["x".repeat(257)], + ..boundary.clone() + }, + ]; + for profile in invalid { + assert_eq!( + database.save_profile(&profile).unwrap_err().code, + NativeErrorCode::ValidationFailed + ); + assert_eq!(database.list_profiles().unwrap(), before_profiles); + assert_eq!(database.pending_outbox().unwrap(), before_outbox); + } +} + +#[test] +fn v5_upgrade_quarantines_invalid_outbox_without_removing_local_profiles_and_recovers_on_save() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v5-invalid-outbox.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + )) + .unwrap(); + let invalid_profile = Profile { + id: "legacy-invalid".into(), + name: "n".repeat(81), + source_path: "/legacy/source".into(), + target_path: "/legacy/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + connection + .execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, '[]', ?5, ?6)", + rusqlite::params![ + invalid_profile.id, + invalid_profile.name, + invalid_profile.source_path, + invalid_profile.target_path, + invalid_profile.created_at, + invalid_profile.updated_at + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000001", + serde_json::to_string(&invalid_profile).unwrap(), + invalid_profile.updated_at, + invalid_profile.id + ], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + assert_eq!( + database.list_profiles().unwrap().as_slice(), + std::slice::from_ref(&invalid_profile) + ); + assert!(database.pending_outbox().unwrap().is_empty()); + let quarantined = database.quarantined_mutations().unwrap(); + assert_eq!(quarantined.len(), 1); + assert_eq!(quarantined[0].profile_id, invalid_profile.id); + assert!(quarantined[0].reason.contains("name")); + database.claim_hosted_account("alice", true).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); + + let corrected = Profile { + name: "Corrected".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid_profile + }; + database.save_profile(&corrected).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + assert_eq!(database.list_profiles().unwrap(), [corrected]); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn deleting_a_quarantined_profile_clears_its_actionable_status_and_queues_a_tombstone() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("quarantine-delete.sqlite3")).unwrap(); + let profile_id = "legacy-invalid"; + let invalid = Profile { + id: profile_id.into(), + name: "n".repeat(81), + source_path: "/source".into(), + target_path: "/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000099", + "upsert", + &serde_json::to_string(&invalid).unwrap(), + &invalid.updated_at, + ) + .unwrap(); + database.hosted_sync_request("alice").unwrap(); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + + database.delete_profile(profile_id).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + let pending = database.pending_outbox().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].kind, "delete"); +} + #[test] fn generates_independent_per_install_vault_passwords() { let first = random_vault_password(); @@ -277,9 +531,26 @@ fn clearing_synced_local_data_is_explicit_and_keeps_run_history() { database .set_sync_cursor("00000000-0000-4000-8000-000000000001", "cursor") .unwrap(); + let invalid_profile = Profile { + name: "n".repeat(81), + ..profile.clone() + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000100", + "upsert", + &serde_json::to_string(&invalid_profile).unwrap(), + &invalid_profile.updated_at, + ) + .unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let _ = database.hosted_sync_request("alice").unwrap(); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + database.clear_local_synced_data().unwrap(); assert!(database.list_profiles().unwrap().is_empty()); assert!(database.pending_outbox().unwrap().is_empty()); + assert!(database.quarantined_mutations().unwrap().is_empty()); assert_eq!(database.sync_cursor().unwrap(), None); assert_eq!(database.run_history().unwrap().len(), 1); } @@ -331,6 +602,51 @@ fn hosted_outbox_contains_only_profiles_and_applies_receipts_transactionally() { assert_eq!(database.run_history().unwrap()[0].id, "private-run"); } +#[test] +fn invalid_hosted_profile_is_rejected_before_profile_or_cursor_persistence() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("invalid-hosted.sqlite3")).unwrap(); + let request = database.hosted_sync_request("subject").unwrap(); + let epoch = request["epoch"].as_str().unwrap(); + let generation = database + .hosted_sync_generation("subject", epoch, "") + .unwrap(); + + let error = database + .apply_hosted_sync_response( + "subject", + epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, + "cursor": "must-not-commit", + "records": [{ + "kind": "profile", + "revision": 1, + "profile": { + "id": "invalid-hosted-profile", + "name": "n".repeat(81), + "sourcePath": "/source", + "targetPath": "/target", + "exclusions": [], + "createdAt": "2026-08-15T00:00:00Z", + "updatedAt": "2026-08-15T00:00:00Z" + } + }], + "receipts": [] + }), + ) + .unwrap_err(); + + assert_eq!(error.code, NativeErrorCode::ValidationFailed); + assert!(database.list_profiles().unwrap().is_empty()); + assert_eq!( + database.sync_cursor().unwrap(), + Some((epoch.into(), String::new())) + ); +} + #[test] fn accepting_a_rotated_account_epoch_drops_stale_outbox_and_respects_local_choice() { let directory = tempdir().unwrap(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 525ec05..6f1b680 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useRef, useState } from "react"; +import { PROFILE_LIMITS, validateSyncProfile } from "@rootline/contracts"; import { DiffTree } from "./components/DiffTree"; import { AuthControls } from "./components/AuthControls"; @@ -56,10 +57,16 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const [error, setError] = useState(); const [rebind, setRebind] = useState(null); const [activeOperation, setActiveOperation] = useState(); + const [deleteCandidate, setDeleteCandidate] = useState(); const headingRef = useRef(null); const scanButtonRef = useRef(null); + const newProfileRef = useRef(null); + const deleteDialogRef = useRef(null); + const deleteCancelRef = useRef(null); + const deleteReturnFocusRef = useRef(null); const returnToScanRef = useRef(false); const profileNameId = useId(); + const deleteDialogTitleId = useId(); const reconcileProfiles = (loaded: Profile[]): void => { setProfiles(loaded); @@ -120,6 +127,10 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina document.documentElement.lang = locale; }, [locale]); + useEffect(() => { + if (deleteCandidate) deleteCancelRef.current?.focus(); + }, [deleteCandidate]); + useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== "Escape" || (step !== "review" && step !== "result")) return; @@ -226,6 +237,36 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina setStep("choose"); }; + const requestProfileDelete = (profile: Profile, returnFocus: HTMLElement): void => { + deleteReturnFocusRef.current = returnFocus; + setDeleteCandidate(profile); + }; + + const closeProfileDelete = (): void => { + setDeleteCandidate(undefined); + queueMicrotask(() => deleteReturnFocusRef.current?.focus()); + }; + + const onDeleteDialogKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + closeProfileDelete(); + return; + } + if (event.key !== "Tab") return; + const buttons = [...(deleteDialogRef.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length === 0) return; + const first = buttons[0]!; + const last = buttons[buttons.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + const onProfileKeyDown = (event: React.KeyboardEvent, index: number): void => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); @@ -235,6 +276,9 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina } else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectProfile(profiles[index]!); + } else if (event.key === "Delete") { + event.preventDefault(); + requestProfileDelete(profiles[index]!, event.currentTarget); } }; @@ -252,18 +296,56 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const now = new Date().toISOString(); const profile: Profile = { id: activeProfile?.id ?? crypto.randomUUID(), - name: profileName.trim() || `${text.source} → ${text.target}`, + name: profileName.trim(), sourcePath, targetPath, exclusions: activeProfile?.exclusions ?? defaultExclusions, createdAt: activeProfile?.createdAt ?? now, updatedAt: now, }; - const saved = await gateway.saveProfile(profile); - setProfiles((current) => [saved, ...current.filter((entry) => entry.id !== saved.id)]); - setActiveProfile(saved); - setProfileName(saved.name); - syncCoordinator?.profileEdited(); + const issue = validateSyncProfile(profile)[0]; + if (issue) { + setError(issue.field === "name" ? text.profileNameInvalid : text.profileInvalid); + return; + } + setError(undefined); + try { + const saved = await gateway.saveProfile(profile); + setProfiles((current) => [saved, ...current.filter((entry) => entry.id !== saved.id)]); + setActiveProfile(saved); + setProfileName(saved.name); + syncCoordinator?.profileEdited(); + } catch (unknownError) { + const failure = nativeFailure(unknownError); + setError(failure.code === "VALIDATION_FAILED" ? text.profileInvalid : text.genericError); + } + }; + + const deleteProfile = async (): Promise => { + if (!deleteCandidate) return; + const deletedId = deleteCandidate.id; + try { + await gateway.deleteProfile(deletedId); + setProfiles((current) => current.filter((profile) => profile.id !== deletedId)); + if (activeProfile?.id === deletedId) { + setActiveProfile(undefined); + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + setRebind(null); + setStep("choose"); + } + setDeleteCandidate(undefined); + setError(undefined); + syncCoordinator?.profileEdited(); + queueMicrotask(() => newProfileRef.current?.focus()); + } catch { + setDeleteCandidate(undefined); + setError(text.genericError); + queueMicrotask(() => deleteReturnFocusRef.current?.focus()); + } }; const createdCount = result?.directories.filter((entry) => entry.status === "created").length ?? 0; @@ -296,7 +378,17 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina ))}
- + + {activeProfile ? ( + + ) : null}
@@ -312,7 +404,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina
- {auth ? : null} + {auth ? : null}
@@ -337,7 +429,14 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina
- setProfileName(event.currentTarget.value)} /> + setProfileName(event.currentTarget.value)} + />
@@ -409,6 +508,25 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina ) : null}
+ {deleteCandidate ? ( +
+
+

{text.deleteProfileTitle(deleteCandidate.name)}

+

{text.deleteProfileBody}

+
+ + +
+
+
+ ) : null} ); } diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts index af668ef..cac8d17 100644 --- a/apps/desktop/src/auth.ts +++ b/apps/desktop/src/auth.ts @@ -17,6 +17,7 @@ import { export const OIDC_SCOPE = "openid profile email permissions offline_access"; export const OIDC_REDIRECT_URI = "rootline://auth/callback"; +export const OIDC_BROWSER_FLOW_TIMEOUT_MS = 5 * 60 * 1000; export interface OidcConfiguration { authority: string; @@ -41,6 +42,8 @@ export interface AuthSnapshot { accountClaimRequired?: boolean; epochResetRequired?: boolean; epochResetPreservesConsentedOutbox?: boolean; + signInPending?: boolean; + quarantinedMutations?: number; error?: string; } @@ -49,6 +52,7 @@ export interface AuthController { subscribe(listener: (snapshot: AuthSnapshot) => void): () => void; initialize(): Promise; signIn(): Promise; + cancelSignIn(): Promise; handleCallback(url: string): Promise; signOut(removeLocalProfiles: boolean): Promise; deleteAccountData(removeLocalProfiles: boolean): Promise; @@ -58,6 +62,12 @@ export interface AuthController { dispose(): void; } +function withoutAuthError(snapshot: AuthSnapshot): AuthSnapshot { + const next = { ...snapshot }; + delete next.error; + return next; +} + interface Environment { VITE_AUTHENTIK_ISSUER?: string; VITE_AUTHENTIK_CLIENT_ID?: string; @@ -203,6 +213,9 @@ export class DesktopAuthController implements AuthController { private initializationFlight: { generation: number; promise: Promise } | undefined; private callbackQueue: Promise = Promise.resolve(); private readonly processedCallbackStates = new Set(); + private signInTimer: ReturnType | undefined; + private signInGeneration = 0; + private signInStateCleanup: Promise = Promise.resolve(); constructor(private readonly config: OidcConfiguration, manager?: UserManager) { if (manager) { @@ -263,19 +276,61 @@ export class DesktopAuthController implements AuthController { } async signIn(): Promise { - this.update({ ...this.current, loading: true }); + if (this.current.signInPending) await this.cancelSignIn(); + this.clearSignInTimer(); + const generation = ++this.signInGeneration; + this.update({ ...withoutAuthError(this.current), loading: true, signInPending: false }); try { + await this.signInStateCleanup; + if (generation !== this.signInGeneration) return; await this.manager.signinRedirect({ nonce: crypto.randomUUID() }); + if (generation !== this.signInGeneration) return; + this.update({ ...withoutAuthError(this.current), loading: false, signInPending: true }); + this.signInTimer = setTimeout(() => { + if (generation !== this.signInGeneration) return; + this.signInTimer = undefined; + this.update({ ...this.current, loading: false, signInPending: false, error: "AUTH_SIGNIN_TIMEOUT" }); + void this.queueSignInStateDiscard(); + }, OIDC_BROWSER_FLOW_TIMEOUT_MS); } catch (error) { - this.update({ - ...this.current, - loading: false, - error: error instanceof Error ? error.message : "The system browser could not be opened.", - }); + if (generation === this.signInGeneration) { + this.update({ + ...this.current, + loading: false, + signInPending: false, + error: error instanceof Error ? error.message : "The system browser could not be opened.", + }); + } throw error; } } + private clearSignInTimer(): void { + if (this.signInTimer) clearTimeout(this.signInTimer); + this.signInTimer = undefined; + } + + private async discardSignInState(): Promise { + try { + const keys = await this.manager.settings.stateStore.getAllKeys(); + await Promise.all(keys.map((key) => this.manager.settings.stateStore.remove(key))); + } catch { + try { await this.manager.clearStaleState(); } catch { /* best-effort protocol-state cleanup */ } + } + } + + private queueSignInStateDiscard(): Promise { + this.signInStateCleanup = this.signInStateCleanup.then(() => this.discardSignInState()); + return this.signInStateCleanup; + } + + async cancelSignIn(): Promise { + this.signInGeneration += 1; + this.clearSignInTimer(); + this.update({ ...withoutAuthError(this.current), loading: false, signInPending: false }); + await this.queueSignInStateDiscard(); + } + handleCallback(rawUrl: string): Promise { const pending = this.callbackQueue.then(() => this.processCallback(rawUrl)); this.callbackQueue = pending.catch(() => undefined); @@ -289,12 +344,15 @@ export class DesktopAuthController implements AuthController { if (this.processedCallbackStates.has(state)) return; this.processedCallbackStates.add(state); const user = await this.manager.signinRedirectCallback(url.toString()); - this.update({ configured: true, loading: false, user: projectUser(user), dataVersion: this.current.dataVersion }); + this.signInGeneration += 1; + this.clearSignInTimer(); + this.update({ configured: true, loading: false, signInPending: false, user: projectUser(user), dataVersion: this.current.dataVersion }); try { await this.sync(); } catch { /* sync() already surfaces reset-required; offline sign-in remains valid */ } } catch (error) { this.update({ configured: true, loading: false, + signInPending: false, user: this.current.user, dataVersion: this.current.dataVersion, error: error instanceof Error ? error.message : "Authentication failed.", @@ -303,6 +361,8 @@ export class DesktopAuthController implements AuthController { } async signOut(removeLocalProfiles: boolean): Promise { + this.signInGeneration += 1; + this.clearSignInTimer(); try { await this.manager.revokeTokens(["access_token", "refresh_token"]); } catch { /* local sign-out must still complete offline */ } await this.manager.removeUser(); await invoke("disconnect_hosted_account", { removeLocalProfiles }); @@ -319,7 +379,9 @@ export class DesktopAuthController implements AuthController { subject: user.profile.sub, removeLocalProfiles, }); - this.update({ ...this.current, dataVersion: this.current.dataVersion + 1 }); + const next = { ...this.current, dataVersion: this.current.dataVersion + 1 }; + if (removeLocalProfiles) delete next.quarantinedMutations; + this.update(next); } async resolveEpochReset(removeLocalProfiles: boolean): Promise { @@ -356,8 +418,14 @@ export class DesktopAuthController implements AuthController { const user = await this.manager.getUser(); if (!user || user.expired || !user.access_token || typeof user.profile.sub !== "string") return; try { - await invoke("sync_hosted_profiles", { apiUrl: this.config.apiUrl, accessToken: user.access_token, subject: user.profile.sub }); - this.update({ ...this.current, dataVersion: this.current.dataVersion + 1 }); + const outcome = await invoke<{ quarantinedMutations?: unknown }>("sync_hosted_profiles", { apiUrl: this.config.apiUrl, accessToken: user.access_token, subject: user.profile.sub }); + const quarantinedMutations = typeof outcome?.quarantinedMutations === "number" && outcome.quarantinedMutations > 0 + ? outcome.quarantinedMutations + : undefined; + const next: AuthSnapshot = { ...this.current, dataVersion: this.current.dataVersion + 1 }; + if (quarantinedMutations === undefined) delete next.quarantinedMutations; + else next.quarantinedMutations = quarantinedMutations; + this.update(next); } catch (error) { const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown; preservesConsentedOutbox?: unknown } }; if (value?.code === "SYNC_EPOCH_RESET_REQUIRED" && typeof value.details?.epoch === "string") { @@ -382,6 +450,8 @@ export class DesktopAuthController implements AuthController { } dispose(): void { + this.signInGeneration += 1; + this.clearSignInTimer(); this.initialization += 1; this.initialized = false; this.initializationFlight = undefined; @@ -395,6 +465,7 @@ class LocalAuthController implements AuthController { subscribe(listener: (snapshot: AuthSnapshot) => void) { listener(this.value); return () => undefined; } initialize() { return Promise.resolve(); } signIn() { return Promise.resolve(); } + cancelSignIn() { return Promise.resolve(); } handleCallback() { return Promise.resolve(); } signOut(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } deleteAccountData(removeLocalProfiles: boolean) { return removeLocalProfiles ? invoke("clear_local_synced_data") : Promise.resolve(); } diff --git a/apps/desktop/src/components/AuthControls.tsx b/apps/desktop/src/components/AuthControls.tsx index 2940231..ee13e86 100644 --- a/apps/desktop/src/components/AuthControls.tsx +++ b/apps/desktop/src/components/AuthControls.tsx @@ -1,13 +1,26 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { AuthController, AuthSnapshot, ProfileSyncCoordinator } from "../auth"; +import { authCopy, type Locale } from "../i18n"; -export function AuthControls({ auth, coordinator }: { auth: AuthController; coordinator?: ProfileSyncCoordinator }): React.JSX.Element | null { +type Choice = "signout" | "delete" | "reset" | "claim"; + +interface AuthControlsProps { + auth: AuthController; + coordinator?: ProfileSyncCoordinator; + locale?: Locale; +} + +export function AuthControls({ auth, coordinator, locale = "en" }: AuthControlsProps): React.JSX.Element | null { + const text = authCopy[locale]; const [snapshot, setSnapshot] = useState(() => auth.snapshot()); - const [choice, setChoice] = useState<"signout" | "delete" | "reset" | "claim" | null>(null); + const [choice, setChoice] = useState(null); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(); const [actionStatus, setActionStatus] = useState(); + const dialogRef = useRef(null); + const dialogCancelRef = useRef(null); + const returnFocusRef = useRef(null); useEffect(() => { const unsubscribe = auth.subscribe(setSnapshot); @@ -15,35 +28,107 @@ export function AuthControls({ auth, coordinator }: { auth: AuthController; coor return () => { unsubscribe(); auth.dispose(); }; }, [auth, coordinator]); - const run = async (action: () => Promise): Promise => { + useEffect(() => { + if (choice) dialogCancelRef.current?.focus(); + }, [choice]); + + const closeChoice = (restoreFocus = true): void => { + setChoice(null); + if (restoreFocus) queueMicrotask(() => returnFocusRef.current?.focus()); + }; + + const openChoice = (next: Choice, trigger: HTMLButtonElement): void => { + returnFocusRef.current = trigger; + setChoice(next); + }; + + const onDialogKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Escape" && !busy) { + event.preventDefault(); + closeChoice(); + return; + } + if (event.key !== "Tab") return; + const buttons = [...(dialogRef.current?.querySelectorAll("button:not(:disabled)") ?? [])]; + if (buttons.length === 0) return; + const first = buttons[0]!; + const last = buttons[buttons.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const run = async (action: () => Promise, announceCompletion = true): Promise => { setBusy(true); setActionError(undefined); setActionStatus(undefined); - try { await action(); setChoice(null); setActionStatus("Account action completed."); } - catch (error) { setActionError(error instanceof Error ? error.message : "Account action failed."); } - finally { setBusy(false); } + try { + await action(); + if (choice) closeChoice(); + if (announceCompletion) setActionStatus(text.actionCompleted); + } catch { + setActionError(text.authFailed); + } finally { + setBusy(false); + } }; + const snapshotError = snapshot.error + ? snapshot.error === "AUTH_SIGNIN_TIMEOUT" ? text.signInTimeout : text.authFailed + : undefined; + if (!snapshot.configured) return null; - if (snapshot.loading) return Connecting account…; - if (!snapshot.user) return
{actionError ?? snapshot.error ? {actionError ?? snapshot.error} : null}
; + if (snapshot.loading) return {text.connecting}; + if (!snapshot.user) { + return ( +
+ {actionError ?? snapshotError ? {actionError ?? snapshotError} : null} + {snapshot.signInPending ? ( + <> + {text.pending} + + + + ) : ( + + )} +
+ ); + } + + const dialogBody = choice === "signout" ? text.dialogBody.signout + : choice === "delete" ? text.dialogBody.delete + : choice === "reset" ? snapshot.epochResetPreservesConsentedOutbox ? text.dialogBody.resetPreserved : text.dialogBody.reset + : text.dialogBody.claim; return (
- {snapshot.user.name ?? snapshot.user.email ?? "Signed in"} - {snapshot.accountClaimRequired ? <>Choose whether this account may upload existing local profiles. : null} - {snapshot.epochResetRequired ? <>Hosted data was reset. Review local profiles before reconnecting. : null} - {actionError ?? snapshot.error ? {actionError ?? snapshot.error} : null} - {busy || actionStatus ? {busy ? "Working…" : actionStatus} : null} - - - + {snapshot.user.name ?? snapshot.user.email ?? text.signedIn} + {snapshot.accountClaimRequired ? <>{text.accountClaimAlert} : null} + {snapshot.epochResetRequired ? <>{text.epochResetAlert} : null} + {actionError ?? snapshotError ? {actionError ?? snapshotError} : null} + {snapshot.quarantinedMutations ? {text.quarantine(snapshot.quarantinedMutations)} : null} + {busy || actionStatus ? {busy ? text.working : actionStatus} : null} + + + {choice ? ( -
-

{choice === "signout" ? "Choose what Rootline keeps on this device." : choice === "delete" ? "Hosted profiles will be permanently deleted and the sync epoch will rotate." : choice === "reset" ? snapshot.epochResetPreservesConsentedOutbox ? "Accept the existing hosted epoch. Explicitly consented local profiles remain queued and will be uploaded; other stale cloud-owned changes are never retained." : "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded." : "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account."}

- - - +
+

{dialogBody}

+ + +
) : null}
diff --git a/apps/desktop/src/i18n.ts b/apps/desktop/src/i18n.ts index fe973d1..46b104a 100644 --- a/apps/desktop/src/i18n.ts +++ b/apps/desktop/src/i18n.ts @@ -1,5 +1,84 @@ export type Locale = "en" | "vi"; +export const authCopy = { + en: { + connecting: "Connecting account…", + signIn: "Sign in", + pending: "Waiting for sign-in in your browser.", + cancelSignIn: "Cancel sign-in", + retrySignIn: "Try sign-in again", + signedIn: "Signed in", + accountClaimAlert: "Choose whether this account may upload existing local profiles.", + reviewLocal: "Review local profiles", + epochResetAlert: "Hosted data was reset. Review local profiles before reconnecting.", + reviewReset: "Review reset", + authFailed: "Rootline could not complete the account action. Try again.", + signInTimeout: "The browser sign-in timed out. You can safely try again.", + actionCompleted: "Account action completed.", + working: "Working…", + syncNow: "Sync now", + signOut: "Sign out", + deleteHosted: "Delete hosted data", + quarantine: (count: number) => `${count.toLocaleString()} local profile ${count === 1 ? "change" : "changes"} could not be uploaded. Edit and save the affected profiles to retry, or delete profiles whose exclusions cannot be corrected.`, + dialogLabel: { + signout: "Sign out options", + delete: "Delete hosted data options", + reset: "Hosted reset options", + claim: "Local profile upload options", + }, + dialogBody: { + signout: "Choose what Rootline keeps on this device. Removing local profiles does not delete run history.", + delete: "Hosted profiles will be permanently deleted and the sync epoch will rotate. Removing local profiles from this device does not delete run history.", + reset: "Accept the new hosted epoch. Stale queued changes will be discarded and will not be uploaded. Removing local profiles does not delete run history.", + resetPreserved: "Accept the existing hosted epoch. Explicitly consented local profiles remain queued and will be uploaded; other stale cloud-owned changes are never retained. Removing local profiles does not delete run history.", + claim: "These profiles may contain absolute paths. Choose whether to upload existing queued profiles to this account.", + }, + uploadExisting: "Upload existing profiles", + keepLocal: "Keep local profiles", + keepLocalOnly: "Keep local only", + removeLocal: "Remove local profiles", + cancel: "Cancel", + }, + vi: { + connecting: "Đang kết nối tài khoản…", + signIn: "Đăng nhập", + pending: "Đang chờ đăng nhập trong trình duyệt.", + cancelSignIn: "Hủy đăng nhập", + retrySignIn: "Thử đăng nhập lại", + signedIn: "Đã đăng nhập", + accountClaimAlert: "Hãy chọn tài khoản này có được tải lên các hồ sơ cục bộ hiện có hay không.", + reviewLocal: "Xem lại hồ sơ cục bộ", + epochResetAlert: "Dữ liệu lưu trữ đã được đặt lại. Hãy xem lại hồ sơ cục bộ trước khi kết nối lại.", + reviewReset: "Xem lại đặt lại", + authFailed: "Rootline không thể hoàn tất thao tác tài khoản. Hãy thử lại.", + signInTimeout: "Phiên đăng nhập trong trình duyệt đã hết hạn. Bạn có thể thử lại an toàn.", + actionCompleted: "Đã hoàn tất thao tác tài khoản.", + working: "Đang xử lý…", + syncNow: "Đồng bộ ngay", + signOut: "Đăng xuất", + deleteHosted: "Xóa dữ liệu lưu trữ", + quarantine: (count: number) => `${count.toLocaleString()} thay đổi hồ sơ cục bộ không thể tải lên. Hãy chỉnh sửa và lưu các hồ sơ bị ảnh hưởng để thử lại, hoặc xóa hồ sơ có quy tắc loại trừ không thể sửa.`, + dialogLabel: { + signout: "Tùy chọn đăng xuất", + delete: "Tùy chọn xóa dữ liệu lưu trữ", + reset: "Tùy chọn đặt lại dữ liệu lưu trữ", + claim: "Tùy chọn tải lên hồ sơ cục bộ", + }, + dialogBody: { + signout: "Chọn dữ liệu Rootline giữ trên thiết bị này. Khi xóa hồ sơ cục bộ, lịch sử lượt chạy vẫn được giữ lại.", + delete: "Các hồ sơ lưu trữ sẽ bị xóa vĩnh viễn và epoch đồng bộ sẽ thay đổi. Xóa hồ sơ cục bộ khỏi thiết bị không xóa lịch sử lượt chạy.", + reset: "Chấp nhận epoch lưu trữ mới. Các thay đổi cũ đang chờ sẽ bị loại bỏ và không được tải lên. Xóa hồ sơ cục bộ không xóa lịch sử lượt chạy.", + resetPreserved: "Chấp nhận epoch lưu trữ hiện có. Các hồ sơ cục bộ đã được đồng ý rõ ràng vẫn nằm trong hàng đợi và sẽ được tải lên; các thay đổi cũ khác do đám mây sở hữu không được giữ lại. Xóa hồ sơ cục bộ không xóa lịch sử lượt chạy.", + claim: "Các hồ sơ này có thể chứa đường dẫn tuyệt đối. Hãy chọn có tải các hồ sơ hiện đang chờ lên tài khoản này hay không.", + }, + uploadExisting: "Tải lên hồ sơ hiện có", + keepLocal: "Giữ hồ sơ cục bộ", + keepLocalOnly: "Chỉ giữ cục bộ", + removeLocal: "Xóa hồ sơ cục bộ", + cancel: "Hủy", + }, +} as const; + export const copy = { en: { localeButton: "Tiếng Việt", @@ -7,6 +86,11 @@ export const copy = { workflowProgress: "Workflow progress", chooseEyebrow: "Structure / additive", newProfile: "New profile", + deleteProfile: (name: string) => `Delete profile ${name}`, + deleteProfileTitle: (name: string) => `Delete profile ${name}?`, + deleteProfileBody: "This removes the profile from this device and queues a hosted tombstone when cloud sync is enabled. Run history is preserved.", + confirmProfileDelete: "Confirm profile deletion", + cancelProfileDelete: "Cancel profile deletion", noProfiles: "No saved profiles", offline: "Offline workspace", chooseTitle: "Choose two roots", @@ -47,6 +131,8 @@ export const copy = { operationCancelled: "The operation was cancelled safely.", save: "Save profile", profileName: "Profile name", + profileNameInvalid: "Profile name must contain 1–80 characters.", + profileInvalid: "This profile exceeds Rootline’s limits. Use a 1–80 character name, paths up to 4,096 characters, and at most 100 exclusion patterns of 1–256 characters.", all: "All", selected: "Selected", clear: "Clear selection", @@ -75,6 +161,11 @@ export const copy = { workflowProgress: "Tiến trình đồng bộ", chooseEyebrow: "Cấu trúc / chỉ bổ sung", newProfile: "Hồ sơ mới", + deleteProfile: (name: string) => `Xóa hồ sơ ${name}`, + deleteProfileTitle: (name: string) => `Xóa hồ sơ ${name}?`, + deleteProfileBody: "Thao tác này xóa hồ sơ khỏi thiết bị và xếp hàng bản ghi xóa khi bật đồng bộ đám mây. Lịch sử lượt chạy vẫn được giữ lại.", + confirmProfileDelete: "Xác nhận xóa hồ sơ", + cancelProfileDelete: "Hủy xóa hồ sơ", noProfiles: "Chưa có hồ sơ đã lưu", offline: "Không gian ngoại tuyến", chooseTitle: "Chọn hai thư mục gốc", @@ -115,6 +206,8 @@ export const copy = { operationCancelled: "Thao tác đã được hủy an toàn.", save: "Lưu hồ sơ", profileName: "Tên hồ sơ", + profileNameInvalid: "Tên hồ sơ phải có từ 1–80 ký tự.", + profileInvalid: "Hồ sơ vượt quá giới hạn cho phép. Hãy dùng tên dài 1–80 ký tự, đường dẫn tối đa 4.096 ký tự và tối đa 100 mẫu loại trừ dài 1–256 ký tự.", all: "Tất cả", selected: "Đã chọn", clear: "Bỏ chọn", diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ab860d6..d5ba215 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -40,15 +40,21 @@ button:disabled { cursor: not-allowed; opacity: .45; } .brand strong, .brand span { display: block; }.brand strong { color: #f5f7f6; font-size: 18px; letter-spacing: -.02em; }.brand span { color: #b8c1bd; font: 10px/1.4 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .08em; } .rail-label { display: flex; justify-content: space-between; padding: 0 8px 9px; color: #b8c1bd; font: 600 10px/1.3 ui-monospace, "SFMono-Regular", monospace; letter-spacing: .12em; text-transform: uppercase; } .profile-list { display: grid; gap: 4px; min-height: 0; overflow-y: auto; } -.profile-item, .new-profile { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; } +.profile-item, .new-profile, .delete-profile { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; } .profile-item { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; align-items: center; padding: 11px 10px; border-radius: 9px; } .profile-item:hover, .profile-item[aria-selected="true"] { background: #ffffff0d; } .profile-item[aria-selected="true"] { box-shadow: inset 2px 0 var(--cyan-500); } .profile-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--graphite-700); }.profile-item[aria-selected="true"] .profile-dot { background: var(--cyan-500); } .profile-item strong, .profile-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.profile-item strong { font-size: 13px; }.profile-item small { margin-top: 3px; color: #b8c1bd; font-size: 10px; } .new-profile { margin-top: 8px; padding: 10px; color: #b8c1bd; font-size: 12px; }.new-profile span { margin-right: 8px; color: var(--cyan-500); } +.delete-profile { padding: 8px 10px; color: #d8a5a1; font-size: 11px; }.delete-profile span { margin-right: 8px; color: var(--danger); } .rail-footer { display: flex; align-items: center; gap: 8px; margin-top: auto; padding: 14px 8px 0; border-top: 1px solid #ffffff14; color: #b8c1bd; font-size: 11px; }.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--moss-500); box-shadow: 0 0 0 3px #587b6428; } +.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: #11161999; } +.confirm-dialog { width: min(100%, 440px); padding: 24px; border: 1px solid var(--fog-300); border-radius: 12px; background: var(--fog-50); box-shadow: 0 24px 80px #0005; } +.confirm-dialog h2 { margin: 0; font-size: 21px; }.confirm-dialog p { margin: 14px 0 22px; color: #66706c; line-height: 1.55; } +.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }.danger-button { min-height: 44px; padding: 10px 16px; border: 1px solid var(--danger); border-radius: 7px; color: white; background: var(--danger); font-weight: 700; font-size: 12px; } + .workbench { min-width: 0; min-height: 100vh; background-image: linear-gradient(#20272a0b 1px, transparent 1px), linear-gradient(90deg, #20272a0b 1px, transparent 1px); background-size: 32px 32px; } .topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 28px; padding: 0 42px; background: color-mix(in srgb, var(--fog-50) 92%, transparent); border-bottom: 1px solid var(--fog-100); } .steps { display: flex; align-items: center; gap: 0; padding: 0; margin: 0; list-style: none; } diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx index 64de033..54356a8 100644 --- a/apps/desktop/src/test/App.test.tsx +++ b/apps/desktop/src/test/App.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import axe from "axe-core"; import { describe, expect, test, vi } from "vitest"; @@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest"; import { App } from "../App"; import { DiffTree } from "../components/DiffTree"; import type { NativeGateway, ScanPlan } from "../native"; -import type { AuthController, AuthSnapshot } from "../auth"; +import type { AuthController, AuthSnapshot, ProfileSyncCoordinator } from "../auth"; const plan: ScanPlan = { operationId: "scan-1", @@ -203,6 +203,7 @@ describe("Rootline desktop workflow", () => { snapshot: () => state, subscribe: (listener) => { listeners.add(listener); listener(state); return () => listeners.delete(listener); }, initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), @@ -215,6 +216,77 @@ describe("Rootline desktop workflow", () => { await waitFor(() => expect(screen.queryByRole("option", { name: "Remote" })).not.toBeInTheDocument()); expect(screen.queryByText("/private/path")).not.toBeInTheDocument(); }); + + test("enforces shared profile limits in the UI with localized errors before native persistence", async () => { + const user = userEvent.setup(); + const native = gateway(); + render(); + const name = screen.getByRole("textbox", { name: "Profile name" }); + expect(name).toHaveAttribute("minlength", "1"); + expect(name).toHaveAttribute("maxlength", "80"); + expect(name).toBeRequired(); + + fireEvent.change(name, { target: { value: "n".repeat(81) } }); + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(screen.getByRole("alert")).toHaveTextContent("Profile name must contain 1–80 characters."); + expect(native.saveProfile).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Lưu hồ sơ" })); + expect(screen.getByRole("alert")).toHaveTextContent("Tên hồ sơ phải có từ 1–80 ký tự."); + expect(native.saveProfile).not.toHaveBeenCalled(); + }); + + test("localizes native profile validation failures without persisting UI state", async () => { + const user = userEvent.setup(); + const native = gateway({ + saveProfile: vi.fn(async () => { throw { code: "VALIDATION_FAILED", message: "raw native limit" }; }), + }); + render(); + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Lưu hồ sơ" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Hồ sơ vượt quá giới hạn cho phép"); + expect(screen.queryByText("raw native limit")).not.toBeInTheDocument(); + }); + + test("deletes a profile through a keyboard-accessible localized confirmation and queues sync", async () => { + const user = userEvent.setup(); + const profile = { + id: "delete-me", name: "Archive", sourcePath: "/archive", targetPath: "/backup", + exclusions: [], createdAt: "x", updatedAt: "x", + }; + const native = gateway({ listProfiles: vi.fn(async () => [profile]) }); + const coordinator = { profileEdited: vi.fn() } as unknown as ProfileSyncCoordinator; + render(); + + const option = await screen.findByRole("option", { name: "Archive" }); + await user.click(option); + option.focus(); + await user.keyboard("{Delete}"); + const englishDialog = screen.getByRole("dialog", { name: "Delete profile Archive?" }); + expect(englishDialog).toHaveTextContent("Run history is preserved"); + expect(screen.getByRole("button", { name: "Cancel profile deletion" })).toHaveFocus(); + await user.keyboard("{Escape}"); + await waitFor(() => expect(option).toHaveFocus()); + + await user.click(screen.getByRole("button", { name: "Tiếng Việt" })); + await user.click(screen.getByRole("button", { name: "Xóa hồ sơ Archive" })); + const vietnameseDialog = screen.getByRole("dialog", { name: "Xóa hồ sơ Archive?" }); + expect(vietnameseDialog).toHaveTextContent("Lịch sử lượt chạy vẫn được giữ lại"); + await user.click(screen.getByRole("button", { name: "Xác nhận xóa hồ sơ" })); + + await waitFor(() => expect(native.deleteProfile).toHaveBeenCalledWith("delete-me")); + expect(screen.queryByRole("option", { name: "Archive" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Tên hồ sơ" })).toHaveValue(""); + expect(coordinator.profileEdited).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Hồ sơ mới" })).toHaveFocus(); + }); }); describe("DiffTree virtualization", () => { diff --git a/apps/desktop/src/test/AuthControls.test.tsx b/apps/desktop/src/test/AuthControls.test.tsx index e9627d6..bed5870 100644 --- a/apps/desktop/src/test/AuthControls.test.tsx +++ b/apps/desktop/src/test/AuthControls.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import axe from "axe-core"; import { expect, test, vi } from "vitest"; import { AuthControls } from "../components/AuthControls"; @@ -13,6 +14,7 @@ test("sign-out explicitly keeps or removes local synced profiles without renderi subscribe: (listener) => { listener(state); return () => undefined; }, initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), @@ -50,6 +52,7 @@ test("requires an explicit keep-or-remove decision after an epoch reset", async snapshot: () => state, subscribe: (listener) => { listener(state); return () => undefined; }, initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), @@ -73,6 +76,7 @@ test("explains that accepting an existing epoch keeps explicitly consented devic snapshot: () => state, subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), @@ -93,6 +97,7 @@ test("requires explicit consent before existing absolute-path profiles are claim snapshot: () => state, subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), sync: vi.fn(async () => undefined), dispose: vi.fn(), @@ -104,3 +109,67 @@ test("requires explicit consent before existing absolute-path profiles are claim await user.click(screen.getByRole("button", { name: "Keep local only" })); expect(auth.resolveAccountClaim).toHaveBeenCalledWith(false); }); + +test("fully localizes controls and provides a trapped, Escape-restoring Vietnamese dialog", async () => { + const user = userEvent.setup(); + const state: AuthSnapshot = { + configured: true, loading: false, dataVersion: 0, + user: { sub: "alice", name: "Alice", permissions: ["rootline:profiles:sync"] }, + }; + const auth = { + snapshot: () => state, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(state); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + const view = render(); + + const trigger = screen.getByRole("button", { name: "Đăng xuất" }); + expect(screen.getByRole("button", { name: "Đồng bộ ngay" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Xóa dữ liệu lưu trữ" })).toBeInTheDocument(); + expect(screen.queryByText("Sync now")).not.toBeInTheDocument(); + await user.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Tùy chọn đăng xuất" }); + expect(dialog).toHaveTextContent(/lịch sử lượt chạy vẫn được giữ lại/i); + const cancel = screen.getByRole("button", { name: "Hủy" }); + expect(cancel).toHaveFocus(); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(screen.getByRole("button", { name: "Xóa hồ sơ cục bộ" })).toHaveFocus(); + await user.keyboard("{Escape}"); + expect(dialog).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect((await axe.run(view.container)).violations).toEqual([]); +}); + +test("offers cancel and retry for a pending browser sign-in and explains quarantined changes", async () => { + const user = userEvent.setup(); + const pending: AuthSnapshot = { configured: true, loading: false, signInPending: true, user: null, dataVersion: 0 }; + const auth = { + snapshot: () => pending, + subscribe: (listener: (snapshot: AuthSnapshot) => void) => { listener(pending); return () => undefined; }, + initialize: vi.fn(async () => undefined), signIn: vi.fn(async () => undefined), + cancelSignIn: vi.fn(async () => undefined), handleCallback: vi.fn(async () => undefined), + signOut: vi.fn(async () => undefined), deleteAccountData: vi.fn(async () => undefined), + resolveEpochReset: vi.fn(async () => undefined), resolveAccountClaim: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), dispose: vi.fn(), + } satisfies AuthController; + const view = render(); + expect(screen.getByRole("status")).toHaveTextContent("Waiting for sign-in in your browser"); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + await user.click(screen.getByRole("button", { name: "Try sign-in again" })); + expect(auth.cancelSignIn).toHaveBeenCalledTimes(1); + expect(auth.signIn).toHaveBeenCalledTimes(1); + + const synced = { ...pending, signInPending: false, user: { sub: "alice", permissions: [] }, quarantinedMutations: 2 } satisfies AuthSnapshot; + view.rerender( synced, + subscribe: (listener) => { listener(synced); return () => undefined; }, + }} locale="en" />); + expect(screen.getByRole("status")).toHaveTextContent("2 local profile changes could not be uploaded"); + expect(screen.getByRole("status")).toHaveTextContent("Edit and save the affected profiles"); + expect(screen.getByRole("status")).toHaveTextContent("delete"); +}); diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts index 1c5183c..2acf696 100644 --- a/apps/desktop/src/test/auth.test.ts +++ b/apps/desktop/src/test/auth.test.ts @@ -3,6 +3,7 @@ import { OidcClient, WebStorageStateStore, type AsyncStorage } from "oidc-client import { DesktopAuthController, + OIDC_BROWSER_FLOW_TIMEOUT_MS, OIDC_SCOPE, ProfileSyncCoordinator, createOidcSettings, @@ -244,6 +245,56 @@ describe("Rootline desktop authentication boundary", () => { expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, user: null, error: "system browser unavailable" })); }); + test("lets an abandoned browser sign-in time out, cancel, and retry without a stuck loading state", async () => { + vi.useFakeTimers(); + const stateStore = { + getAllKeys: vi.fn(async () => ["pending-state"]), + remove: vi.fn(async () => "stored-request"), + }; + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(), + clearStaleState: vi.fn(async () => undefined), + revokeTokens: vi.fn(), + removeUser: vi.fn(), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + + await auth.signIn(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: true })); + await auth.cancelSignIn(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: false })); + expect(stateStore.remove).toHaveBeenCalledWith("pending-state"); + expect(manager.clearStaleState).not.toHaveBeenCalled(); + + let releaseTimeoutCleanup!: () => void; + const timeoutCleanup = new Promise((resolve) => { + releaseTimeoutCleanup = () => resolve("stored-request"); + }); + stateStore.remove.mockImplementationOnce(() => timeoutCleanup); + await auth.signIn(); + await vi.advanceTimersByTimeAsync(OIDC_BROWSER_FLOW_TIMEOUT_MS); + await vi.waitFor(() => expect(stateStore.remove).toHaveBeenCalledTimes(2)); + expect(auth.snapshot()).toEqual(expect.objectContaining({ + loading: false, + signInPending: false, + error: "AUTH_SIGNIN_TIMEOUT", + })); + const retry = auth.signIn(); + await Promise.resolve(); + expect(manager.signinRedirect).toHaveBeenCalledTimes(2); + releaseTimeoutCleanup(); + await retry; + expect(manager.signinRedirect).toHaveBeenCalledTimes(3); + expect(auth.snapshot()).toEqual(expect.objectContaining({ loading: false, signInPending: true })); + expect(auth.snapshot().error).toBeUndefined(); + auth.dispose(); + vi.useRealTimers(); + }); + test("cleans a partial listener registration before retrying initialization", async () => { const firstDeepLinkUnlisten = vi.fn(); const secondDeepLinkUnlisten = vi.fn(); @@ -316,4 +367,40 @@ describe("Rootline desktop authentication boundary", () => { epochResetPreservesConsentedOutbox: true, })); }); + + test("publishes an actionable count when native sync quarantines invalid legacy mutations", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockResolvedValue({ quarantinedMutations: 2 }); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.sync(); + expect(auth.snapshot()).toEqual(expect.objectContaining({ quarantinedMutations: 2 })); + }); + + test("clears quarantine status when hosted deletion also removes local profiles", async () => { + const storedUser = { + profile: { sub: "alice", permissions: ["rootline:profiles:sync"] }, + access_token: "access", expired: false, + }; + const manager = { + getUser: vi.fn(async () => storedUser), signinRedirect: vi.fn(), + signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), + }; + tauriMocks.invoke.mockImplementation(async (command: string) => + command === "sync_hosted_profiles" ? { quarantinedMutations: 2 } : undefined); + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.sync(); + + await auth.deleteAccountData(true); + + expect(auth.snapshot().quarantinedMutations).toBeUndefined(); + }); }); diff --git a/docs/configuration.md b/docs/configuration.md index 60d3052..7018298 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,7 +24,7 @@ Configuration resolution is deliberately narrow: an explicit `--config` path win ## Desktop profiles -A saved profile contains a display name, absolute source and target paths, exclusions, timestamps, and additive sync mode. It is local by default. Changing a folder outside Rootline may require re-binding the profile before a new scan. The desktop always supports offline profiles and run history without authentication. +A saved profile contains a display name, absolute source and target paths, exclusions, timestamps, and additive sync mode. Names contain 1–80 characters, each path 1–4096 characters, and the exclusion list at most 100 patterns of 1–256 characters. It is local by default. Changing a folder outside Rootline may require re-binding the profile before a new scan. The desktop always supports offline profiles and run history without authentication. Removing a local profile does not remove its run history. ## Optional hosted sync build variables diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 64525d5..35e1c90 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -18,7 +18,7 @@ Production registration is an external deployment gate. Create an Authentik OAut | **Permission claim** | `rootline:profiles:sync` in the `permissions` array | | **Signing algorithm** | RS256 | -Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. The Tauri deep-link listener is installed before vault loading, cached cold-start URLs are read with `getCurrent`, and each callback state is consumed once. The deep-link plugin is the only URL delivery path, including Windows single-instance forwarding. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. +Do not issue or embed a client secret. The desktop opens the system browser and validates the exact callback scheme, host, path, state, PKCE verifier, and OIDC nonce before accepting a session. A browser flow left unfinished returns to a cancelable, retryable state after five minutes instead of leaving the desktop busy indefinitely. The Tauri deep-link listener is installed before vault loading, cached cold-start URLs are read with `getCurrent`, and each callback state is consumed once. The deep-link plugin is the only URL delivery path, including Windows single-instance forwarding. OIDC state and tokens use Tauri Stronghold; the per-install random vault password is stored in the operating-system credential manager, never browser storage or React component state. Set all three desktop build variables together: @@ -34,7 +34,7 @@ Profiles saved before the first sign-in remain unclaimed. Because they contain a If a second device explicitly consents to upload pre-login profiles but discovers an existing server epoch, accepting that epoch preserves only those consented, not-yet-cloud-owned mutation chains. Each mutation keeps that provenance until its own successful receipt. An edit or deletion of the same consented profile before adoption inherits the marker, preserving order and preventing an older upsert from overwriting or resurrecting it; unrelated edits queued after account binding are cloud-owned and are discarded by a later reset. Once every consented chain is acknowledged, later reset adoption clears the outbox so deleted hosted data cannot be resurrected. -Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, mutation generation, and a random lifecycle generation. The response must still match that state inside the same SQLite transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance rotate the lifecycle generation; local profile save/delete changes only the mutation generation. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. Every follow-up request is constructed under one database critical section that requires the captured lifecycle and prohibits auto-binding; lifecycle is checked again immediately after transport, before interpreting even a reset response. The loop retries a rejected response only while that lifecycle remains current. This permits same-session local-edit replay while sign-out, removal, account switch, epoch adoption, and same-account ABA stop without rebuilding a stale request, rebinding the old subject, or reusing its captured token. Repeating an already committed claim for the same subject is idempotent. +Native sync calls are serialized. Each request captures the verified subject plus the local epoch, starting cursor, mutation generation, and a random lifecycle generation. Before opening a SQLite transaction, a successful response must contain exactly one receipt for every mutation sent—no duplicate, missing, or extra IDs. The response must still match the captured state inside the same transaction before any profile, receipt, or cursor is applied. Sign-out, account changes, and epoch acceptance rotate the lifecycle generation; local profile save/delete changes only the mutation generation. After receipts are removed, a response also skips any profile or tombstone that still has a pending local mutation in a later batch. Every follow-up request is constructed under one database critical section that requires the captured lifecycle and prohibits auto-binding; lifecycle is checked again immediately after transport, before interpreting even a reset response. The loop retries a rejected response only while that lifecycle remains current. This permits same-session local-edit replay while sign-out, removal, account switch, epoch adoption, and same-account ABA stop without rebuilding a stale request, rebinding the old subject, or reusing its captured token. Invalid legacy outbox mutations are removed from the FIFO transactionally and recorded in a metadata-only quarantine; the local profile remains available to edit and save again. Repeating an already committed claim for the same subject is idempotent. ## API deployment @@ -69,11 +69,11 @@ The stable deployment must provide TLS termination for the API, TLS validation f | **`POST /v1/sync`** | Authenticated profile mutations and cursor delta | | **`DELETE /v1/account-data`** | Deletes hosted data and rotates the user's epoch | -Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names are limited to 120 characters, paths to 4096 characters, and exclusions to 100 entries of 512 characters each. +Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names contain 1–80 characters, source and target paths contain 1–4096 characters, and exclusions contain at most 100 patterns of 1–256 characters each. The shared contract, API DTO, desktop editor, and native persistence boundary enforce these same limits. The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. -Server commit arrival order is last-write-wins. Every accepted mutation advances a per-user revision, deletes become tombstones, and mutation receipts remain eligible for idempotent replay for 90 days. Expired receipts are purged opportunistically during a later sync. Account deletion clears profiles, tombstones, changes, and receipts, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. +Server commit arrival order is last-write-wins. Every new mutation advances a per-user revision and deletes become tombstones. Mutation receipt rows are physically retained for 90 days and purged opportunistically during a later sync, while a compact content-bound deduplication record remains for the lifetime of the account epoch. Replaying the same mutation ID and payload after receipt expiry therefore acknowledges its original revision without another write; reusing the ID with different content returns 409. Account deletion clears profiles, tombstones, changes, receipts, and deduplication records, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. Mutation IDs are bound to a canonical content hash; reuse with different content returns 409 instead of silently dropping a change. Delta pages contain at most 100 records and approximately 1 MiB of record JSON. `hasMore` and the returned cursor let the desktop drain long-offline deltas while enforcing a 2 MiB streaming response cap. diff --git a/docs/privacy.md b/docs/privacy.md index 0cb9c78..809a351 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -23,7 +23,7 @@ Because profiles contain absolute paths, they can reveal usernames, drive layout ## Retention and deletion -Local profiles and capped run history remain until the user removes them or chooses local-data removal during sign-out/account removal. Hosted mutation receipts remain eligible for idempotent replay for 90 days. Expired receipts are eligible for cleanup after 90 days and are purged opportunistically on a later sync. Deleting account data removes hosted profiles, tombstones, changes, and receipts, then rotates the epoch so a stale device cannot silently restore them. +Local profiles remain until the user deletes them or chooses local-profile removal during sign-out/account removal. Deleting local profiles does not delete the capped run history; run history remains local and ages out only through its bounded-history policy. Hosted mutation receipts are physically retained for 90 days. Expired receipt rows are eligible for cleanup after 90 days and are purged opportunistically on a later sync. A compact content-bound deduplication record remains for the lifetime of the account epoch so replaying an expired receipt cannot create another revision. Deleting hosted account data removes profiles, tombstones, changes, receipts, and that deduplication record, then rotates the epoch so a stale device cannot silently restore them. Production logs are metadata-only. Operators must not log bearer tokens, request bodies, profile fields, or absolute paths. Backups containing hosted profiles must be encrypted and governed by the deployment's retention policy. diff --git a/package.json b/package.json index 40b825f..e6d8f5d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "packageManager": "pnpm@10.33.0", "scripts": { + "audit:prod": "pnpm audit --prod --audit-level high", "build": "pnpm -r --if-present run build", "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", "test": "pnpm -r --if-present run test", @@ -17,6 +18,11 @@ "validate:workflows": "node --test tests/release-workflows.test.mjs", "typecheck": "pnpm -r --if-present run typecheck" }, + "pnpm": { + "overrides": { + "js-yaml@>=5.0.0 <=5.2.1": "5.2.2" + } + }, "devDependencies": { "@eslint/js": "^9.39.1", "@types/node": "^24.0.0", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cea97c1..3574b56 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,47 @@ /** The only synchronization direction supported by Rootline v1. */ +import profileLimits from "./profile-limits.json" with { type: "json" }; + +export const PROFILE_LIMITS = profileLimits; + +export interface ProfileValidationIssue { + field: string; + min?: number; + max: number; +} + +function characterLength(value: string): number { + return [...value].length; +} + +/** Runtime validation shared by profile editors and transport boundaries. */ +export function validateSyncProfile( + profile: Pick, +): ProfileValidationIssue[] { + const issues: ProfileValidationIssue[] = []; + const lengths = [ + ["name", profile.name, PROFILE_LIMITS.name], + ["sourcePath", profile.sourcePath, PROFILE_LIMITS.path], + ["targetPath", profile.targetPath, PROFILE_LIMITS.path], + ] as const; + for (const [field, value, limits] of lengths) { + const length = characterLength(value); + if (length < limits.min || length > limits.max) { + issues.push({ field, min: limits.min, max: limits.max }); + } + } + if (profile.exclusions.length > PROFILE_LIMITS.exclusions.max) { + issues.push({ field: "exclusions", max: PROFILE_LIMITS.exclusions.max }); + } + profile.exclusions.forEach((pattern, index) => { + const length = characterLength(pattern); + const limits = PROFILE_LIMITS.exclusions.pattern; + if (length < limits.min || length > limits.max) { + issues.push({ field: `exclusions[${index}]`, min: limits.min, max: limits.max }); + } + }); + return issues; +} + export const SYNC_MODES = ["additive"] as const; export type SyncMode = (typeof SYNC_MODES)[number]; diff --git a/packages/contracts/src/profile-limits.json b/packages/contracts/src/profile-limits.json new file mode 100644 index 0000000..36516e5 --- /dev/null +++ b/packages/contracts/src/profile-limits.json @@ -0,0 +1,5 @@ +{ + "name": { "min": 1, "max": 80 }, + "path": { "min": 1, "max": 4096 }, + "exclusions": { "max": 100, "pattern": { "min": 1, "max": 256 } } +} diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index fb1b548..d7767b1 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -46,4 +46,36 @@ describe("Rootline contracts", () => { expect(contracts.CLOUD_MUTATION_KINDS).toEqual(["upsert", "delete"]); expect(contracts.PROFILE_RECORD_KINDS).toEqual(["profile", "tombstone"]); }); + + it("exports the exact shared profile limits and runtime validation", () => { + expect(contracts.PROFILE_LIMITS).toEqual({ + name: { min: 1, max: 80 }, + path: { min: 1, max: 4096 }, + exclusions: { max: 100, pattern: { min: 1, max: 256 } }, + }); + const valid = { + id: "profile", + name: "n".repeat(80), + sourcePath: "/".repeat(4096), + targetPath: "C".repeat(4096), + exclusions: Array.from({ length: 100 }, () => "x".repeat(256)), + createdAt: "2026-08-15T00:00:00Z", + updatedAt: "2026-08-15T00:00:00Z", + }; + expect(contracts.validateSyncProfile(valid)).toEqual([]); + expect(contracts.validateSyncProfile({ + ...valid, + name: "n".repeat(81), + sourcePath: "", + targetPath: "t".repeat(4097), + exclusions: [...valid.exclusions, "", "x".repeat(257)], + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "name" }), + expect.objectContaining({ field: "sourcePath" }), + expect.objectContaining({ field: "targetPath" }), + expect.objectContaining({ field: "exclusions" }), + expect.objectContaining({ field: "exclusions[100]" }), + expect.objectContaining({ field: "exclusions[101]" }), + ])); + }); }); diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 5285d28..7b6cf53 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "resolveJsonModule": true }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c1bc0..5502d29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + js-yaml@>=5.0.0 <=5.2.1: 5.2.2 + importers: .: @@ -56,6 +59,9 @@ importers: '@prisma/client': specifier: ^6.16.2 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + '@rootline/contracts': + specifier: workspace:* + version: link:../../packages/contracts class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -108,6 +114,9 @@ importers: apps/desktop: dependencies: + '@rootline/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@tauri-apps/api': specifier: ^2.8.0 version: 2.11.1 @@ -1786,8 +1795,8 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - js-yaml@5.2.1: - resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + js-yaml@5.2.2: + resolution: {integrity: sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==} hasBin: true jsdom@26.1.0: @@ -2933,7 +2942,7 @@ snapshots: '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - js-yaml: 5.2.1 + js-yaml: 5.2.2 lodash: 4.18.1 path-to-regexp: 8.4.2 reflect-metadata: 0.2.2 @@ -4103,7 +4112,7 @@ snapshots: dependencies: argparse: 2.0.1 - js-yaml@5.2.1: + js-yaml@5.2.2: dependencies: argparse: 2.0.1 diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index b9aef54..7c2bb99 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -29,6 +29,26 @@ test("all workflows are valid YAML", () => { } }); +test("production dependency audits fail closed before CI and every release mutation", () => { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + assert.equal(manifest.scripts["audit:prod"], "pnpm audit --prod --audit-level high"); + assert.equal(manifest.pnpm.overrides["js-yaml@>=5.0.0 <=5.2.1"], "5.2.2"); + + for (const name of workflows) { + assert.match(workflow(name), /pnpm audit --prod --audit-level high/, `${name} must enforce the production audit`); + } + const npmRelease = workflow("release-npm.yml"); + const apiRelease = workflow("release-api.yml"); + const desktopRelease = workflow("release-desktop.yml"); + assert.ok(npmRelease.indexOf("pnpm audit --prod --audit-level high") < npmRelease.indexOf("npm publish")); + assert.ok(apiRelease.indexOf("pnpm audit --prod --audit-level high") < apiRelease.indexOf("docker/build-push-action")); + assert.ok(desktopRelease.indexOf("pnpm audit --prod --audit-level high") < desktopRelease.indexOf("tauri signer sign")); + + const lockfile = readFileSync(join(root, "pnpm-lock.yaml"), "utf8"); + assert.match(lockfile, /js-yaml@5\.2\.2/); + assert.doesNotMatch(lockfile, /js-yaml@5\.2\.1/); +}); + test("every third-party action is pinned to a full immutable commit SHA", () => { for (const name of workflows) { const actionReferences = [...workflow(name).matchAll(/uses:\s*([\w.-]+\/[\w.-]+)@([^\s#]+)/g)]; From 038e58c2af5205fd951c28185d8c63a1d813c219 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 11:36:39 +0700 Subject: [PATCH 18/26] fix: close Rootline v2 follow-up review gaps --- .github/workflows/ci.yml | 12 + .github/workflows/release-api.yml | 7 + apps/api/Dockerfile | 8 +- apps/api/scripts/test-docker-image.sh | 94 +++++ apps/api/src/code-point-length.validator.ts | 26 ++ apps/api/src/sync.dto.ts | 10 +- apps/api/test/sync.e2e.spec.ts | 15 +- .../0007_profile_sync_provenance.sql | 20 + apps/desktop/src-tauri/src/lib.rs | 368 +++++++++++++++--- .../src-tauri/tests/hosted_sync_postgres.rs | 39 +- apps/desktop/src-tauri/tests/native.rs | 337 +++++++++++++++- apps/desktop/src/App.tsx | 4 +- apps/desktop/src/auth.ts | 7 + apps/desktop/src/test/App.test.tsx | 43 +- apps/desktop/src/test/auth.test.ts | 63 +++ package.json | 1 + packages/contracts/src/index.ts | 9 +- packages/contracts/src/profile-limits.json | 1 + packages/contracts/test/contracts.test.ts | 17 +- tests/release-workflows.test.mjs | 20 + 20 files changed, 1005 insertions(+), 96 deletions(-) create mode 100644 apps/api/scripts/test-docker-image.sh create mode 100644 apps/api/src/code-point-length.validator.ts create mode 100644 apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eead057..cad74d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,18 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm --filter folder-structure-sync test:pack + api-image-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:api-image + postgres-integration: runs-on: ubuntu-24.04 services: diff --git a/.github/workflows/release-api.yml b/.github/workflows/release-api.yml index 4848edc..d15a68b 100644 --- a/.github/workflows/release-api.yml +++ b/.github/workflows/release-api.yml @@ -87,6 +87,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:api-image - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 81f0657..5d47805 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -13,10 +13,14 @@ WORKDIR /app RUN corepack enable COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ COPY apps/api/package.json apps/api/package.json +COPY packages/contracts/package.json packages/contracts/package.json RUN pnpm install --frozen-lockfile --filter @rootline/api... COPY apps/api apps/api -RUN pnpm --filter @rootline/api prisma:generate && pnpm --filter @rootline/api build +COPY packages/contracts packages/contracts +RUN pnpm --filter @rootline/contracts build \ + && pnpm --filter @rootline/api prisma:generate \ + && pnpm --filter @rootline/api build FROM base AS runtime @@ -32,6 +36,8 @@ COPY --from=build --chown=rootline:rootline /app/apps/api/node_modules /app/apps COPY --from=build --chown=rootline:rootline /app/apps/api/dist /app/apps/api/dist COPY --from=build --chown=rootline:rootline /app/apps/api/package.json /app/apps/api/package.json COPY --from=build --chown=rootline:rootline /app/apps/api/prisma /app/apps/api/prisma +COPY --from=build --chown=rootline:rootline /app/packages/contracts/dist /app/packages/contracts/dist +COPY --from=build --chown=rootline:rootline /app/packages/contracts/package.json /app/packages/contracts/package.json USER rootline EXPOSE 3000 diff --git a/apps/api/scripts/test-docker-image.sh b/apps/api/scripts/test-docker-image.sh new file mode 100644 index 0000000..f3fd958 --- /dev/null +++ b/apps/api/scripts/test-docker-image.sh @@ -0,0 +1,94 @@ +#!/bin/sh +set -eu + +smoke_suffix="${ROOTLINE_API_SMOKE_SUFFIX:-$$}" +smoke_image="rootline-api-smoke:${smoke_suffix}" +smoke_network="rootline-api-smoke-${smoke_suffix}" +postgres_container="rootline-api-smoke-postgres-${smoke_suffix}" +api_container="rootline-api-smoke-api-${smoke_suffix}" +build_id="rootline-api-smoke-${smoke_suffix}" +smoke_directory=$(mktemp -d "${TMPDIR:-/tmp}/rootline-api-image-smoke.XXXXXX") +jwks_path="$smoke_directory/jwks.json" + +cleanup() { + docker rm --force "$api_container" "$postgres_container" >/dev/null 2>&1 || true + docker network rm "$smoke_network" >/dev/null 2>&1 || true + docker image rm "$smoke_image" >/dev/null 2>&1 || true + rm -f "$jwks_path" + rmdir "$smoke_directory" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +node - "$jwks_path" <<'NODE' +const { generateKeyPairSync } = require("node:crypto"); +const { writeFileSync } = require("node:fs"); +const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const key = publicKey.export({ format: "jwk" }); +Object.assign(key, { kid: "rootline-image-smoke", alg: "RS256", use: "sig" }); +writeFileSync(process.argv[2], JSON.stringify({ keys: [key] })); +NODE + +docker build --file apps/api/Dockerfile \ + --build-arg "ROOTLINE_BUILD_ID=$build_id" \ + --tag "$smoke_image" . + +docker network create "$smoke_network" >/dev/null +docker run --detach \ + --name "$postgres_container" \ + --network "$smoke_network" \ + --network-alias postgres \ + --publish 127.0.0.1::5432 \ + --tmpfs /var/lib/postgresql/data \ + --env POSTGRES_USER=rootline \ + --env POSTGRES_PASSWORD=rootline \ + --env POSTGRES_DB=rootline_smoke \ + postgres:16-alpine >/dev/null + +postgres_ready=false +for attempt in $(seq 1 30); do + if docker exec "$postgres_container" pg_isready --username rootline --dbname rootline_smoke >/dev/null 2>&1; then + postgres_ready=true + break + fi + if [ "$attempt" -eq 30 ]; then + docker logs "$postgres_container" + else + sleep 1 + fi +done +[ "$postgres_ready" = true ] + +postgres_mapping=$(docker port "$postgres_container" 5432/tcp) +postgres_port=${postgres_mapping##*:} +host_database_url="postgresql://rootline:rootline@127.0.0.1:${postgres_port}/rootline_smoke?schema=public" +DATABASE_URL="$host_database_url" pnpm --filter @rootline/api exec prisma migrate deploy + +docker run --detach \ + --name "$api_container" \ + --network "$smoke_network" \ + --read-only \ + --tmpfs /tmp \ + --mount "type=bind,source=$jwks_path,target=/run/rootline/jwks.json,readonly" \ + --env DATABASE_URL="postgresql://rootline:rootline@postgres:5432/rootline_smoke?schema=public" \ + --env JWT_ISSUER=https://auth.rootline.invalid/application/o/rootline/ \ + --env JWT_AUDIENCE=rootline-desktop-smoke \ + --env JWT_JWKS_PATH=/run/rootline/jwks.json \ + "$smoke_image" >/dev/null + +api_healthy=false +for attempt in $(seq 1 30); do + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$api_container") + if [ "$health" = healthy ]; then + api_healthy=true + break + fi + if [ "$health" = unhealthy ] || [ "$attempt" -eq 30 ]; then + docker logs "$api_container" + else + sleep 1 + fi +done +[ "$api_healthy" = true ] + +docker exec "$api_container" node -e \ + "fetch('http://127.0.0.1:3000/healthz').then(async response => { const body = await response.json(); if (!response.ok || body.status !== 'ok' || body.buildId !== '$build_id') process.exit(1); }).catch(() => process.exit(1))" diff --git a/apps/api/src/code-point-length.validator.ts b/apps/api/src/code-point-length.validator.ts new file mode 100644 index 0000000..f69f2fd --- /dev/null +++ b/apps/api/src/code-point-length.validator.ts @@ -0,0 +1,26 @@ +import { codePointLength } from "@rootline/contracts"; +import { buildMessage, type ValidationOptions, ValidateBy } from "class-validator"; + +export function CodePointLength( + min: number, + max: number, + validationOptions?: ValidationOptions, +): PropertyDecorator { + return ValidateBy( + { + name: "codePointLength", + constraints: [min, max], + validator: { + validate: (value): boolean => + typeof value === "string" + && codePointLength(value) >= min + && codePointLength(value) <= max, + defaultMessage: buildMessage( + (eachPrefix) => `${eachPrefix}$property must contain between $constraint1 and $constraint2 Unicode code points`, + validationOptions, + ), + }, + }, + validationOptions, + ); +} diff --git a/apps/api/src/sync.dto.ts b/apps/api/src/sync.dto.ts index 524cde1..47f8e33 100644 --- a/apps/api/src/sync.dto.ts +++ b/apps/api/src/sync.dto.ts @@ -3,17 +3,17 @@ import { PROFILE_LIMITS } from "@rootline/contracts"; import { ArrayMaxSize, IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested, } from "class-validator"; +import { CodePointLength } from "./code-point-length.validator.js"; export class SyncProfileDto { @IsString() @MinLength(1) @MaxLength(128) id!: string; - @IsString() @MinLength(PROFILE_LIMITS.name.min) @MaxLength(PROFILE_LIMITS.name.max) name!: string; - @IsString() @MinLength(PROFILE_LIMITS.path.min) @MaxLength(PROFILE_LIMITS.path.max) sourcePath!: string; - @IsString() @MinLength(PROFILE_LIMITS.path.min) @MaxLength(PROFILE_LIMITS.path.max) targetPath!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.name.min, PROFILE_LIMITS.name.max) name!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.path.min, PROFILE_LIMITS.path.max) sourcePath!: string; + @IsString() @CodePointLength(PROFILE_LIMITS.path.min, PROFILE_LIMITS.path.max) targetPath!: string; @IsArray() @ArrayMaxSize(PROFILE_LIMITS.exclusions.max) @IsString({ each: true }) - @MinLength(PROFILE_LIMITS.exclusions.pattern.min, { each: true }) - @MaxLength(PROFILE_LIMITS.exclusions.pattern.max, { each: true }) + @CodePointLength(PROFILE_LIMITS.exclusions.pattern.min, PROFILE_LIMITS.exclusions.pattern.max, { each: true }) exclusions!: string[]; @IsDateString() createdAt!: string; @IsDateString() updatedAt!: string; diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index d64ed28..e4055fb 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -277,20 +277,21 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { test("enforces the shared profile limits at their exact boundaries", async () => { const auth = await token("profile-limit-user"); - const boundary = mutation(PROFILE_ID, "n".repeat(80), "00000000-0000-4000-8000-000000000521"); - boundary.profile.sourcePath = "s".repeat(4096); - boundary.profile.targetPath = "t".repeat(4096); - boundary.profile.exclusions = Array.from({ length: 100 }, () => "x".repeat(256)); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); + const boundary = mutation(PROFILE_ID, exactCodePoints(80), "00000000-0000-4000-8000-000000000521"); + boundary.profile.sourcePath = exactCodePoints(4096); + boundary.profile.targetPath = exactCodePoints(4096); + boundary.profile.exclusions = Array.from({ length: 100 }, () => exactCodePoints(256)); await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) .send({ deviceId: "profile-limits", epoch: EPOCH, mutations: [boundary] }).expect(200); const invalidProfiles = [ - { ...boundary.profile, name: "n".repeat(81) }, + { ...boundary.profile, name: exactCodePoints(81) }, { ...boundary.profile, sourcePath: "" }, - { ...boundary.profile, targetPath: "t".repeat(4097) }, + { ...boundary.profile, targetPath: exactCodePoints(4097) }, { ...boundary.profile, exclusions: Array.from({ length: 101 }, () => "x") }, { ...boundary.profile, exclusions: [""] }, - { ...boundary.profile, exclusions: ["x".repeat(257)] }, + { ...boundary.profile, exclusions: [exactCodePoints(257)] }, ]; for (const [index, profile] of invalidProfiles.entries()) { const invalid = { diff --git a/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql new file mode 100644 index 0000000..bbaab5b --- /dev/null +++ b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql @@ -0,0 +1,20 @@ +ALTER TABLE mutation_quarantine ADD COLUMN provenance TEXT NOT NULL DEFAULT 'account-bound'; +ALTER TABLE mutation_quarantine ADD COLUMN subject TEXT NOT NULL DEFAULT ''; + +UPDATE mutation_quarantine +SET subject = COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), ''), + provenance = CASE + WHEN COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = '' THEN 'pre-login' + ELSE 'account-bound' + END; + +CREATE TABLE profile_sync_policy ( + profile_id TEXT PRIMARY KEY, + policy TEXT NOT NULL CHECK (policy IN ('unclaimed', 'local-only', 'consented')), + subject TEXT NOT NULL DEFAULT '' +); + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT profile_id, 'unclaimed', '' +FROM mutation_quarantine +WHERE provenance = 'pre-login' AND profile_id <> ''; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1620bdf..b3e488f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -648,7 +648,6 @@ pub fn scan_plan( token.check()?; let source = canonical_directory(&request.source_path, "source")?; let target = canonical_directory(&request.target_path, "target")?; - validate_relationship(&source, &target, !cfg!(any(target_os = "macos", windows)))?; let target_case_sensitive = detect_case_sensitive(&target)?; validate_relationship(&source, &target, target_case_sensitive)?; let source_snapshot = scan_root(&source, &request.exclusions, token)?; @@ -848,6 +847,8 @@ struct ExclusionLimits { #[derive(Debug, Deserialize)] struct ProfileLimits { + #[serde(rename = "lengthUnit")] + length_unit: String, name: CharacterLimits, path: CharacterLimits, exclusions: ExclusionLimits, @@ -877,6 +878,7 @@ fn validate_profile(profile: &Profile) -> Result<(), NativeError> { return Err(profile_validation_error("id", Some(1), 128)); } let limits = profile_limits(); + assert_eq!(limits.length_unit, "unicode-code-points"); for (field, value, limit) in [ ("name", profile.name.as_str(), &limits.name), ("sourcePath", profile.source_path.as_str(), &limits.path), @@ -960,9 +962,18 @@ fn outbox_validation_error( } fn quarantine_invalid_outbox(connection: &mut Connection) -> Result { + let owner = connection + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); let candidates = { let mut statement = connection.prepare( - "SELECT sequence, mutation_id, kind, payload, occurred_at, profile_id + "SELECT sequence, mutation_id, kind, payload, occurred_at, profile_id, + preserve_on_epoch_adopt FROM mutation_outbox ORDER BY sequence ASC", )?; let rows = statement.query_map([], |row| { @@ -973,25 +984,54 @@ fn quarantine_invalid_outbox(connection: &mut Connection) -> Result(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, + row.get::<_, i64>(6)?, )) })?; rows.collect::, _>>()? }; let transaction = connection.transaction()?; let mut quarantined = 0; - for (sequence, mutation_id, kind, payload, occurred_at, profile_id) in candidates { + for (sequence, mutation_id, kind, payload, occurred_at, profile_id, preserve) in candidates { let Some(reason) = outbox_validation_error(&mutation_id, &kind, &payload, &occurred_at) else { continue; }; transaction.execute( - "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) - VALUES (?1, ?2, ?3, ?4) + "INSERT INTO mutation_quarantine( + mutation_id, kind, profile_id, reason, provenance, subject + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(mutation_id) DO UPDATE SET kind=excluded.kind, profile_id=excluded.profile_id, reason=excluded.reason, + provenance=excluded.provenance, subject=excluded.subject, quarantined_at=CURRENT_TIMESTAMP", - params![mutation_id, kind, profile_id, reason], + params![ + mutation_id, + kind, + profile_id, + reason, + if owner.is_empty() { + "pre-login" + } else { + "account-bound" + }, + owner + ], )?; + if !profile_id.is_empty() && owner.is_empty() { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'unclaimed', '') + ON CONFLICT(profile_id) DO NOTHING", + [&profile_id], + )?; + } else if !profile_id.is_empty() && preserve == 1 { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'consented', ?2) + ON CONFLICT(profile_id) DO UPDATE SET policy='consented', subject=excluded.subject", + params![profile_id, owner], + )?; + } transaction.execute( "DELETE FROM mutation_outbox WHERE sequence = ?1", [sequence], @@ -1023,6 +1063,7 @@ pub struct QuarantinedMutation { pub mutation_id: String, pub profile_id: String, pub reason: String, + pub provenance: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -1059,6 +1100,10 @@ const MIGRATIONS: &[(i64, &str)] = &[ 6, include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), ), + ( + 7, + include_str!("../migrations/0007_profile_sync_provenance.sql"), + ), ]; const HOSTED_SYNC_MUTATION_LIMIT: usize = 100; const HOSTED_SYNC_BODY_LIMIT: usize = 256 * 1024; @@ -1122,6 +1167,52 @@ impl Database { .map_err(|error| NativeError::new(NativeErrorCode::Internal, error.to_string()))?; let mut connection = self.connection(); let transaction = connection.transaction()?; + let owner = transaction + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + if owner.is_empty() { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + VALUES (?1, 'unclaimed', '') + ON CONFLICT(profile_id) DO UPDATE SET policy='unclaimed', subject=''", + [&profile.id], + )?; + } + let policy = transaction + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let policy_matches_owner = policy + .as_ref() + .is_some_and(|(_, subject)| subject == &owner); + let local_only = policy_matches_owner + && policy + .as_ref() + .is_some_and(|(policy, _)| policy == "local-only"); + let policy_owner_mismatch = !owner.is_empty() + && policy + .as_ref() + .is_some_and(|(_, subject)| subject != &owner); + let preserve = policy_matches_owner + && policy + .as_ref() + .is_some_and(|(policy, _)| policy == "consented") + || transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + WHERE profile_id=?1 AND preserve_on_epoch_adopt=1 + )", + [&profile.id], + |row| row.get::<_, i64>(0), + )? == 1; transaction.execute( "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) @@ -1135,20 +1226,25 @@ impl Database { "DELETE FROM mutation_quarantine WHERE profile_id = ?1", [&profile.id], )?; - transaction.execute( - "INSERT INTO mutation_outbox( - mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt - ) VALUES ( - ?1, 'upsert', ?2, ?3, ?4, - EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?4 AND preserve_on_epoch_adopt=1) - )", - params![ - Uuid::new_v4().to_string(), - payload, - profile.updated_at, - profile.id - ], - )?; + if local_only || policy_owner_mismatch { + transaction.execute( + "DELETE FROM mutation_outbox WHERE profile_id=?1", + [&profile.id], + )?; + } else { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES (?1, 'upsert', ?2, ?3, ?4, ?5)", + params![ + Uuid::new_v4().to_string(), + payload, + profile.updated_at, + profile.id, + i64::from(preserve) + ], + )?; + } transaction.execute( "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", [], @@ -1181,25 +1277,57 @@ impl Database { pub fn delete_profile(&self, id: &str) -> Result<(), NativeError> { let mut connection = self.connection(); let transaction = connection.transaction()?; + let owner = transaction + .query_row( + "SELECT subject FROM sync_state WHERE singleton=1", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .unwrap_or_default(); + let policy = transaction + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let local_only = policy + .as_ref() + .is_some_and(|(policy, subject)| policy == "local-only" && subject == &owner); + let preserve = policy + .as_ref() + .is_some_and(|(policy, subject)| policy == "consented" && subject == &owner) + || transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + WHERE profile_id=?1 AND preserve_on_epoch_adopt=1 + )", + [id], + |row| row.get::<_, i64>(0), + )? == 1; transaction.execute("DELETE FROM profiles WHERE id = ?1", [id])?; transaction.execute( "DELETE FROM mutation_quarantine WHERE profile_id = ?1", [id], )?; - transaction.execute( - "INSERT INTO mutation_outbox( - mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt - ) VALUES ( - ?1, 'delete', ?2, ?3, ?4, - EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?4 AND preserve_on_epoch_adopt=1) - )", - params![ - Uuid::new_v4().to_string(), - json!({ "profileId": id }).to_string(), - timestamp(), - id - ], - )?; + if local_only { + transaction.execute("DELETE FROM mutation_outbox WHERE profile_id=?1", [id])?; + transaction.execute("DELETE FROM profile_sync_policy WHERE profile_id=?1", [id])?; + } else { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, preserve_on_epoch_adopt + ) VALUES (?1, 'delete', ?2, ?3, ?4, ?5)", + params![ + Uuid::new_v4().to_string(), + json!({ "profileId": id }).to_string(), + timestamp(), + id, + i64::from(preserve) + ], + )?; + } transaction.execute( "UPDATE sync_state SET session_generation=session_generation + 1 WHERE singleton=1", [], @@ -1253,7 +1381,7 @@ impl Database { pub fn quarantined_mutations(&self) -> Result, NativeError> { let connection = self.connection(); let mut statement = connection.prepare( - "SELECT mutation_id, profile_id, reason + "SELECT mutation_id, profile_id, reason, provenance FROM mutation_quarantine ORDER BY sequence ASC", )?; let rows = statement.query_map([], |row| { @@ -1261,6 +1389,7 @@ impl Database { mutation_id: row.get(0)?, profile_id: row.get(1)?, reason: row.get(2)?, + provenance: row.get(3)?, }) })?; Ok(rows.collect::, _>>()?) @@ -1316,6 +1445,7 @@ impl Database { transaction.execute("DELETE FROM profiles", [])?; transaction.execute("DELETE FROM mutation_outbox", [])?; transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; transaction.execute("DELETE FROM sync_state", [])?; transaction.commit()?; Ok(()) @@ -1329,6 +1459,18 @@ impl Database { let transaction = connection.transaction()?; let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute("DELETE FROM mutation_outbox", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + if !remove_local_profiles { + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'unclaimed', '' FROM profiles", + [], + )?; + transaction.execute( + "UPDATE mutation_quarantine SET provenance='pre-login', subject=''", + [], + )?; + } transaction.execute( "INSERT INTO sync_state(singleton, subject, epoch, cursor, session_generation, preserve_outbox_on_epoch_adopt, lifecycle_generation) @@ -1375,14 +1517,89 @@ impl Database { "Local hosted-sync state belongs to another account.", )); } - if owner.as_deref() == Some(subject) { + let same_owner = owner.as_deref() == Some(subject); + if same_owner && !upload_existing { transaction.commit()?; return Ok(()); } if !upload_existing { transaction.execute("DELETE FROM mutation_outbox", [])?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='local-only', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; } else { - transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; + let profiles_to_upload = { + let mut statement = transaction.prepare( + "SELECT p.id, p.name, p.source_path, p.target_path, p.exclusions_json, + p.created_at, p.updated_at + FROM profiles p + JOIN profile_sync_policy policy ON policy.profile_id=p.id + WHERE policy.policy='unclaimed' + OR (policy.policy='local-only' AND policy.subject=?1)", + )?; + let rows = statement.query_map([subject], |row| { + let exclusions: String = row.get(4)?; + Ok(Profile { + id: row.get(0)?, + name: row.get(1)?, + source_path: row.get(2)?, + target_path: row.get(3)?, + exclusions: serde_json::from_str(&exclusions).unwrap_or_default(), + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + })?; + rows.collect::, _>>()? + }; + if !same_owner { + transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; + } + for profile in profiles_to_upload { + if validate_profile(&profile).is_err() { + continue; + } + let already_queued: i64 = transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + [&profile.id], + |row| row.get(0), + )?; + if already_queued == 0 { + transaction.execute( + "INSERT INTO mutation_outbox( + mutation_id, kind, payload, occurred_at, profile_id, + preserve_on_epoch_adopt + ) VALUES (?1, 'upsert', ?2, ?3, ?4, 1)", + params![ + Uuid::new_v4().to_string(), + serde_json::to_string(&profile).map_err(internal_error)?, + profile.updated_at, + profile.id + ], + )?; + } + } + transaction.execute( + "UPDATE profile_sync_policy SET policy='consented', subject=?1 + WHERE policy='unclaimed' OR (policy='local-only' AND subject=?1)", + [subject], + )?; + } + if same_owner { + let lifecycle_generation = Uuid::new_v4().to_string(); + transaction.execute( + "UPDATE sync_state SET + session_generation=session_generation + 1, + preserve_outbox_on_epoch_adopt=CASE WHEN EXISTS( + SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + ) THEN 1 ELSE preserve_outbox_on_epoch_adopt END, + lifecycle_generation=?1 + WHERE singleton=1 AND subject=?2", + params![lifecycle_generation, subject], + )?; + transaction.commit()?; + return Ok(()); } let epoch = Uuid::new_v4().to_string(); let lifecycle_generation = Uuid::new_v4().to_string(); @@ -1418,8 +1635,11 @@ impl Database { .query_row( "SELECT EXISTS( SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 ) FROM sync_state - WHERE singleton=1 AND subject=?1 AND preserve_outbox_on_epoch_adopt=1", + WHERE singleton=1 AND subject=?1", [subject], |row| row.get::<_, i64>(0), ) @@ -1436,6 +1656,7 @@ impl Database { if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; } let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( @@ -1463,8 +1684,11 @@ impl Database { .query_row( "SELECT EXISTS( SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 ) FROM sync_state - WHERE singleton=1 AND subject=?1 AND preserve_outbox_on_epoch_adopt=1", + WHERE singleton=1 AND subject=?1", [subject], |row| row.get::<_, i64>(0), ) @@ -1507,6 +1731,18 @@ impl Database { if remove_local_profiles { transaction.execute("DELETE FROM profiles", [])?; transaction.execute("DELETE FROM mutation_quarantine", [])?; + transaction.execute("DELETE FROM profile_sync_policy", [])?; + } else { + transaction.execute("DELETE FROM profile_sync_policy", [])?; + transaction.execute( + "INSERT INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'local-only', ?1 FROM profiles", + [expected_subject], + )?; + transaction.execute( + "UPDATE mutation_quarantine SET subject=?1 WHERE provenance='pre-login'", + [expected_subject], + )?; } let lifecycle_generation = Uuid::new_v4().to_string(); transaction.execute( @@ -1532,7 +1768,28 @@ impl Database { expected_lifecycle_generation: Option<&str>, ) -> Result<(serde_json::Value, String), NativeError> { let mut connection = self.connection(); + let claim_required_before_quarantine: i64 = connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE provenance='pre-login' + )", + [], + |row| row.get(0), + )?; quarantine_invalid_outbox(&mut connection)?; + let account_claim_required = claim_required_before_quarantine == 1 + || connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE provenance='pre-login' + )", + [], + |row| row.get::<_, i64>(0), + )? == 1; let device_id: String = match connection .query_row( "SELECT value FROM settings WHERE key='device_id'", @@ -1597,7 +1854,7 @@ impl Database { (epoch, cursor, lifecycle_generation) } Some((owner, _, _, _)) if owner.is_empty() => { - if !pending.is_empty() { + if account_claim_required { return Err(NativeError::new( NativeErrorCode::SyncAccountClaimRequired, "Choose whether this account may upload existing local profiles.", @@ -1621,7 +1878,7 @@ impl Database { )) } None => { - if !pending.is_empty() { + if account_claim_required { return Err(NativeError::new( NativeErrorCode::SyncAccountClaimRequired, "Choose whether this account may upload existing local profiles.", @@ -1826,6 +2083,14 @@ impl Database { "A hosted mutation receipt is invalid.", ) })?; + transaction.execute( + "DELETE FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + AND profile_id=( + SELECT profile_id FROM mutation_outbox WHERE mutation_id=?2 + )", + params![expected_subject, mutation_id], + )?; transaction.execute( "DELETE FROM mutation_outbox WHERE mutation_id = ?1", [mutation_id], @@ -1845,7 +2110,13 @@ impl Database { .map_err(internal_error)?; validate_profile(&profile)?; let has_pending_local_mutation: i64 = transaction.query_row( - "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE profile_id=?1 + )", [&profile.id], |row| row.get(0), )?; @@ -1872,7 +2143,13 @@ impl Database { ) })?; let has_pending_local_mutation: i64 = transaction.query_row( - "SELECT EXISTS(SELECT 1 FROM mutation_outbox WHERE profile_id=?1)", + "SELECT EXISTS( + SELECT 1 FROM mutation_outbox WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM mutation_quarantine WHERE profile_id=?1 + UNION ALL + SELECT 1 FROM profile_sync_policy WHERE profile_id=?1 + )", [profile_id], |row| row.get(0), )?; @@ -1894,7 +2171,10 @@ impl Database { preserve_outbox_on_epoch_adopt=CASE WHEN EXISTS( SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 - ) THEN preserve_outbox_on_epoch_adopt + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?2 + ) THEN 1 ELSE 0 END WHERE singleton = 1 AND subject = ?2 AND epoch = ?3 AND cursor = ?4 AND session_generation = ?5", diff --git a/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs index 30d182a..d9346a6 100644 --- a/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs +++ b/apps/desktop/src-tauri/tests/hosted_sync_postgres.rs @@ -16,6 +16,14 @@ fn post_sync( .unwrap() } +fn exact_code_points(count: usize) -> String { + format!( + "{}{}", + "✈️".repeat(count / 2), + if count % 2 == 1 { "x" } else { "" } + ) +} + #[test] #[ignore = "run by the real PostgreSQL API harness"] fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_account() { @@ -26,10 +34,10 @@ fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_accoun let database = Database::open(directory.path().join("device-two.sqlite3")).unwrap(); let local = Profile { id: "device-two-offline-profile".into(), - name: "Device two offline".into(), - source_path: "/Users/device-two/private/source".into(), - target_path: "/Volumes/device-two/backup".into(), - exclusions: vec![".git".into()], + name: exact_code_points(80), + source_path: exact_code_points(4096), + target_path: exact_code_points(4096), + exclusions: (0..100).map(|_| exact_code_points(256)).collect(), created_at: "2026-08-15T00:00:00.000Z".into(), updated_at: "2026-08-15T00:00:00.000Z".into(), }; @@ -50,9 +58,11 @@ fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_accoun assert_eq!(database.pending_outbox().unwrap().len(), 1); let reconnect = database.hosted_sync_request("seam-alice").unwrap(); - assert!(reconnect - .to_string() - .contains("/Users/device-two/private/source")); + assert_eq!(reconnect["mutations"][0]["profile"]["name"], local.name); + assert_eq!( + reconnect["mutations"][0]["profile"]["sourcePath"], + local.source_path + ); let generation = database .hosted_sync_generation("seam-alice", &server_epoch, "") .unwrap(); @@ -64,11 +74,24 @@ fn replays_a_consented_device_two_outbox_without_leaking_paths_to_another_accoun .apply_hosted_sync_response("seam-alice", &server_epoch, "", generation, &body) .unwrap(); assert!(database.pending_outbox().unwrap().is_empty()); + let persisted = database + .list_profiles() + .unwrap() + .into_iter() + .find(|profile| profile.id == local.id) + .expect("accepted Unicode profile must reach desktop SQLite"); + assert_eq!(persisted, local); + assert_eq!(database.sync_cursor().unwrap().unwrap().1, body["cursor"]); database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("seam-bob").unwrap_err().code, + rootline_desktop::NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("seam-bob", false).unwrap(); let bob = database.hosted_sync_request("seam-bob").unwrap(); assert_eq!(bob["mutations"], serde_json::json!([])); - assert!(!bob.to_string().contains("device-two/private")); + assert!(!bob.to_string().contains(&local.source_path)); let bob_epoch = bob["epoch"].as_str().unwrap().to_owned(); let bob_generation = database .hosted_sync_generation("seam-bob", &bob_epoch, "") diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index 5be65c1..e872fb9 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -16,6 +16,57 @@ fn request(source: &std::path::Path, target: &std::path::Path) -> ScanRequest { } } +fn create_v5_database_with_invalid_profile(path: &std::path::Path, profile: &Profile) { + let connection = Connection::open(path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + )) + .unwrap(); + connection + .execute( + "INSERT INTO profiles(id, name, source_path, target_path, exclusions_json, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, '[]', ?5, ?6)", + rusqlite::params![ + profile.id, + profile.name, + profile.source_path, + profile.target_path, + profile.created_at, + profile.updated_at + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000001", + serde_json::to_string(profile).unwrap(), + profile.updated_at, + profile.id + ], + ) + .unwrap(); +} + #[test] fn scans_additively_and_revalidates_before_mkdir() { let source = tempdir().unwrap(); @@ -182,6 +233,31 @@ fn case_detection_is_read_only_and_overlap_wins_before_target_inspection() { let implementation = include_str!("../src/lib.rs"); assert!(!implementation.contains(".rootline-case-probe")); assert!(!implementation.contains("create_new(true)")); + assert!(!implementation.contains("validate_relationship(&source, &target, !cfg!")); +} + +#[test] +fn actual_volume_metadata_controls_case_distinct_sibling_overlap() { + let parent = tempdir().unwrap(); + let case_sensitive = detect_case_sensitive(parent.path()).unwrap(); + let source = parent.path().join("Foo"); + let target = parent.path().join("foo"); + fs::create_dir(&source).unwrap(); + fs::create_dir(source.join("nested")).unwrap(); + + if case_sensitive { + fs::create_dir(&target).unwrap(); + let plan = scan_plan(&request(&source, &target), &CancellationToken::default()).unwrap(); + assert_eq!(plan.missing, ["nested"]); + assert!(plan.target_case_sensitive); + } else { + assert_eq!( + scan_plan(&request(&source, &target), &CancellationToken::default()) + .unwrap_err() + .code, + NativeErrorCode::PathOverlap, + ); + } } #[cfg(unix)] @@ -306,12 +382,19 @@ fn migrates_and_persists_offline_state_with_bounded_history() { fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { let directory = tempdir().unwrap(); let database = Database::open(directory.path().join("profile-limits.sqlite3")).unwrap(); + let exact_code_points = |count: usize| { + format!( + "{}{}", + "✈️".repeat(count / 2), + if count % 2 == 1 { "x" } else { "" } + ) + }; let boundary = Profile { id: "boundary".into(), - name: "n".repeat(80), - source_path: "s".repeat(4096), - target_path: "t".repeat(4096), - exclusions: (0..100).map(|_| "x".repeat(256)).collect(), + name: exact_code_points(80), + source_path: exact_code_points(4096), + target_path: exact_code_points(4096), + exclusions: (0..100).map(|_| exact_code_points(256)).collect(), created_at: "2026-08-15T00:00:00Z".into(), updated_at: "2026-08-15T00:00:00Z".into(), }; @@ -322,7 +405,7 @@ fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { let invalid = [ Profile { id: "bad-name".into(), - name: "n".repeat(81), + name: exact_code_points(81), ..boundary.clone() }, Profile { @@ -332,7 +415,7 @@ fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { }, Profile { id: "bad-target".into(), - target_path: "t".repeat(4097), + target_path: exact_code_points(4097), ..boundary.clone() }, Profile { @@ -342,7 +425,7 @@ fn native_profile_limits_are_enforced_before_profile_or_outbox_persistence() { }, Profile { id: "bad-pattern".into(), - exclusions: vec!["x".repeat(257)], + exclusions: vec![exact_code_points(257)], ..boundary.clone() }, ]; @@ -429,11 +512,33 @@ fn v5_upgrade_quarantines_invalid_outbox_without_removing_local_profiles_and_rec assert_eq!(quarantined.len(), 1); assert_eq!(quarantined[0].profile_id, invalid_profile.id); assert!(quarantined[0].reason.contains("name")); - database.claim_hosted_account("alice", true).unwrap(); + assert_eq!(quarantined[0].provenance, "pre-login"); assert_eq!( - database.hosted_sync_request("alice").unwrap()["mutations"], - serde_json::json!([]) + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired ); + database.claim_hosted_account("alice", true).unwrap(); + let consented_empty = database.hosted_sync_request("alice").unwrap(); + assert_eq!(consented_empty["mutations"], serde_json::json!([])); + let local_epoch = consented_empty["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &local_epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &local_epoch, + "", + generation, + &serde_json::json!({ + "epoch": local_epoch, "cursor": "empty-consented", "records": [], "receipts": [] + }), + ) + .unwrap(); + assert!(database.preserves_consented_outbox("alice").unwrap()); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000701", false) + .unwrap(); let corrected = Profile { name: "Corrected".into(), @@ -450,6 +555,205 @@ fn v5_upgrade_quarantines_invalid_outbox_without_removing_local_profiles_and_rec .len(), 1 ); + assert_eq!(database.pending_outbox().unwrap().len(), 1); +} + +#[test] +fn v6_upgrade_backfills_existing_prelogin_quarantine_provenance() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-quarantine.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5); + {} + INSERT INTO schema_migrations(version) VALUES (6); + INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000006', 'upsert', + 'legacy-prelogin', 'legacy invalid profile');", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), + )) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let quarantined = database.quarantined_mutations().unwrap(); + assert_eq!(quarantined.len(), 1); + assert_eq!(quarantined[0].profile_id, "legacy-prelogin"); + assert_eq!(quarantined[0].provenance, "pre-login"); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired, + ); +} + +#[test] +fn keep_local_only_survives_quarantine_correction_epoch_and_account_switch_without_path_leakage() { + let directory = tempdir().unwrap(); + let path = directory.path().join("quarantine-local-only.sqlite3"); + let invalid = Profile { + id: "legacy-private".into(), + name: "n".repeat(81), + source_path: "/Users/alice/private/source".into(), + target_path: "/Volumes/alice/private/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + create_v5_database_with_invalid_profile(&path, &invalid); + let database = Database::open(&path).unwrap(); + + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + let corrected = Profile { + name: "Corrected local only".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid + }; + database.save_profile(&corrected).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + let alice = database.hosted_sync_request("alice").unwrap(); + assert_eq!(alice["mutations"], serde_json::json!([])); + assert!(!alice.to_string().contains("/Users/alice/private")); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000702", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + + database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("bob", false).unwrap(); + let mut bob_correction = corrected.clone(); + bob_correction.name = "Still local only".into(); + bob_correction.updated_at = "2026-08-15T00:02:00Z".into(); + database.save_profile(&bob_correction).unwrap(); + let bob = database.hosted_sync_request("bob").unwrap(); + assert_eq!(bob["mutations"], serde_json::json!([])); + assert!(!bob.to_string().contains("/Users/alice/private")); +} + +#[test] +fn quarantined_profile_blocks_remote_upsert_and_tombstone_until_corrected_save_and_delete() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("quarantine-conflict.sqlite3")).unwrap(); + let mut local = Profile { + id: "quarantine-conflict".into(), + name: "Preserved local".into(), + source_path: "/local/source".into(), + target_path: "/local/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + database.save_profile(&local).unwrap(); + database.claim_hosted_account("alice", true).unwrap(); + let initial = database.hosted_sync_request("alice").unwrap(); + let epoch = initial["epoch"].as_str().unwrap().to_owned(); + let generation = database + .hosted_sync_generation("alice", &epoch, "") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "baseline", "records": [], + "receipts": [{ "mutationId": initial["mutations"][0]["mutationId"], "revision": 1 }] + }), + ) + .unwrap(); + let invalid = Profile { + name: "n".repeat(81), + ..local.clone() + }; + database + .enqueue_mutation( + "00000000-0000-4000-8007-000000000099", + "upsert", + &serde_json::to_string(&invalid).unwrap(), + &invalid.updated_at, + ) + .unwrap(); + let quarantined_request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(quarantined_request["mutations"], serde_json::json!([])); + assert_eq!(database.quarantined_mutations().unwrap().len(), 1); + + let generation = database + .hosted_sync_generation("alice", &epoch, "baseline") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "baseline", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "remote-upsert", "receipts": [], + "records": [{ + "kind": "profile", "revision": 2, + "profile": { + "id": local.id, "name": "Remote overwrite", "sourcePath": "/remote/source", + "targetPath": "/remote/target", "exclusions": [], + "createdAt": local.created_at, "updatedAt": "2026-08-15T01:00:00Z" + } + }] + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap(), [local.clone()]); + + let generation = database + .hosted_sync_generation("alice", &epoch, "remote-upsert") + .unwrap(); + database + .apply_hosted_sync_response( + "alice", + &epoch, + "remote-upsert", + generation, + &serde_json::json!({ + "epoch": epoch, "cursor": "remote-delete", "receipts": [], + "records": [{ "kind": "tombstone", "revision": 3, "profileId": local.id }] + }), + ) + .unwrap(); + assert_eq!(database.list_profiles().unwrap(), [local.clone()]); + + local.name = "Corrected local".into(); + local.updated_at = "2026-08-15T02:00:00Z".into(); + database.save_profile(&local).unwrap(); + assert!(database.quarantined_mutations().unwrap().is_empty()); + assert_eq!(database.pending_outbox().unwrap().len(), 1); + database.delete_profile(&local.id).unwrap(); + assert!(database.list_profiles().unwrap().is_empty()); + assert_eq!( + database.pending_outbox().unwrap().last().unwrap().kind, + "delete" + ); } #[test] @@ -474,6 +778,11 @@ fn deleting_a_quarantined_profile_clears_its_actionable_status_and_queues_a_tomb &invalid.updated_at, ) .unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", true).unwrap(); database.hosted_sync_request("alice").unwrap(); assert_eq!(database.quarantined_mutations().unwrap().len(), 1); @@ -1326,6 +1635,14 @@ fn hosted_state_cannot_cross_accounts_and_signout_quarantines_pending_paths() { database.disconnect_hosted_account(false).unwrap(); assert_eq!(database.list_profiles().unwrap(), [profile]); + assert_eq!( + database + .hosted_sync_request("account-bob") + .unwrap_err() + .code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("account-bob", false).unwrap(); let bob = database.hosted_sync_request("account-bob").unwrap(); assert_eq!(bob["mutations"], serde_json::json!([])); assert!(uuid::Uuid::parse_str(bob["epoch"].as_str().unwrap()).is_ok()); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6f1b680..7d9bc02 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from "react"; -import { PROFILE_LIMITS, validateSyncProfile } from "@rootline/contracts"; +import { validateSyncProfile } from "@rootline/contracts"; import { DiffTree } from "./components/DiffTree"; import { AuthControls } from "./components/AuthControls"; @@ -433,8 +433,6 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina id={profileNameId} value={profileName} required - minLength={PROFILE_LIMITS.name.min} - maxLength={PROFILE_LIMITS.name.max} onChange={(event) => setProfileName(event.currentTarget.value)} /> diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts index cac8d17..a371149 100644 --- a/apps/desktop/src/auth.ts +++ b/apps/desktop/src/auth.ts @@ -288,6 +288,7 @@ export class DesktopAuthController implements AuthController { this.update({ ...withoutAuthError(this.current), loading: false, signInPending: true }); this.signInTimer = setTimeout(() => { if (generation !== this.signInGeneration) return; + this.signInGeneration += 1; this.signInTimer = undefined; this.update({ ...this.current, loading: false, signInPending: false, error: "AUTH_SIGNIN_TIMEOUT" }); void this.queueSignInStateDiscard(); @@ -338,17 +339,23 @@ export class DesktopAuthController implements AuthController { } private async processCallback(rawUrl: string): Promise { + const lifecycleGeneration = this.signInGeneration; try { const url = validateCallbackUrl(rawUrl); const state = url.searchParams.get("state")!; if (this.processedCallbackStates.has(state)) return; this.processedCallbackStates.add(state); const user = await this.manager.signinRedirectCallback(url.toString()); + if (lifecycleGeneration !== this.signInGeneration) { + try { await this.manager.removeUser(); } catch { /* stale callbacks must never restore a session */ } + return; + } this.signInGeneration += 1; this.clearSignInTimer(); this.update({ configured: true, loading: false, signInPending: false, user: projectUser(user), dataVersion: this.current.dataVersion }); try { await this.sync(); } catch { /* sync() already surfaces reset-required; offline sign-in remains valid */ } } catch (error) { + if (lifecycleGeneration !== this.signInGeneration) return; this.update({ configured: true, loading: false, diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx index 54356a8..6da9c43 100644 --- a/apps/desktop/src/test/App.test.tsx +++ b/apps/desktop/src/test/App.test.tsx @@ -220,16 +220,32 @@ describe("Rootline desktop workflow", () => { test("enforces shared profile limits in the UI with localized errors before native persistence", async () => { const user = userEvent.setup(); const native = gateway(); - render(); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); + const boundary = { + id: "limits", + name: exactCodePoints(80), + sourcePath: exactCodePoints(4096), + targetPath: exactCodePoints(4096), + exclusions: Array.from({ length: 100 }, () => exactCodePoints(256)), + createdAt: "x", + updatedAt: "x", + }; + const view = render(); const name = screen.getByRole("textbox", { name: "Profile name" }); - expect(name).toHaveAttribute("minlength", "1"); - expect(name).toHaveAttribute("maxlength", "80"); + expect(name).not.toHaveAttribute("minlength"); + expect(name).not.toHaveAttribute("maxlength"); expect(name).toBeRequired(); - fireEvent.change(name, { target: { value: "n".repeat(81) } }); + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(native.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ + name: boundary.name, + sourcePath: boundary.sourcePath, + targetPath: boundary.targetPath, + exclusions: boundary.exclusions, + })); + vi.mocked(native.saveProfile).mockClear(); + + fireEvent.change(name, { target: { value: exactCodePoints(81) } }); await user.click(screen.getByRole("button", { name: "Save profile" })); expect(screen.getByRole("alert")).toHaveTextContent("Profile name must contain 1–80 characters."); expect(native.saveProfile).not.toHaveBeenCalled(); @@ -238,6 +254,19 @@ describe("Rootline desktop workflow", () => { await user.click(screen.getByRole("button", { name: "Lưu hồ sơ" })); expect(screen.getByRole("alert")).toHaveTextContent("Tên hồ sơ phải có từ 1–80 ký tự."); expect(native.saveProfile).not.toHaveBeenCalled(); + + view.unmount(); + for (const invalidProfile of [ + { ...boundary, id: "path-over", name: "Valid", sourcePath: exactCodePoints(4097) }, + { ...boundary, id: "pattern-over", name: "Valid", exclusions: [exactCodePoints(257)] }, + { ...boundary, id: "array-over", name: "Valid", exclusions: Array.from({ length: 101 }, () => "x") }, + ]) { + const invalidView = render(); + await user.click(screen.getByRole("button", { name: "Save profile" })); + expect(screen.getByRole("alert")).toHaveTextContent("This profile exceeds Rootline’s limits."); + expect(native.saveProfile).not.toHaveBeenCalled(); + invalidView.unmount(); + } }); test("localizes native profile validation failures without persisting UI state", async () => { diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts index 2acf696..ffd10ba 100644 --- a/apps/desktop/src/test/auth.test.ts +++ b/apps/desktop/src/test/auth.test.ts @@ -295,6 +295,69 @@ describe("Rootline desktop authentication boundary", () => { vi.useRealTimers(); }); + test("does not publish or retain a callback already in flight when sign-in is cancelled", async () => { + const stateStore = { getAllKeys: vi.fn(async () => []), remove: vi.fn() }; + const callbackUser = { + profile: { sub: "cancelled-user", permissions: ["rootline:profiles:sync"] }, + access_token: "cancelled-access", + expired: false, + }; + let releaseCallback!: (user: typeof callbackUser) => void; + const callbackResult = new Promise((resolve) => { releaseCallback = resolve; }); + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(() => callbackResult), + clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(async () => undefined), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.signIn(); + const callback = auth.handleCallback("rootline://auth/callback?code=cancelled&state=cancelled-state"); + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + await auth.cancelSignIn(); + releaseCallback(callbackUser); + await callback; + + expect(auth.snapshot().user).toBeNull(); + expect(manager.removeUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.invoke).not.toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + }); + + test("does not publish or retain a callback already in flight when browser sign-in times out", async () => { + vi.useFakeTimers(); + const stateStore = { getAllKeys: vi.fn(async () => []), remove: vi.fn() }; + const callbackUser = { + profile: { sub: "timed-out-user", permissions: ["rootline:profiles:sync"] }, + access_token: "timed-out-access", + expired: false, + }; + let releaseCallback!: (user: typeof callbackUser) => void; + const callbackResult = new Promise((resolve) => { releaseCallback = resolve; }); + const manager = { + settings: { stateStore }, + getUser: vi.fn(async () => null), + signinRedirect: vi.fn(async () => undefined), + signinRedirectCallback: vi.fn(() => callbackResult), + clearStaleState: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(async () => undefined), + }; + const auth = new DesktopAuthController(config, manager as never); + await auth.initialize(); + await auth.signIn(); + const callback = auth.handleCallback("rootline://auth/callback?code=timeout&state=timeout-state"); + await vi.waitFor(() => expect(manager.signinRedirectCallback).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(OIDC_BROWSER_FLOW_TIMEOUT_MS); + releaseCallback(callbackUser); + await callback; + + expect(auth.snapshot()).toEqual(expect.objectContaining({ user: null, error: "AUTH_SIGNIN_TIMEOUT" })); + expect(manager.removeUser).toHaveBeenCalledTimes(1); + expect(tauriMocks.invoke).not.toHaveBeenCalledWith("sync_hosted_profiles", expect.anything()); + auth.dispose(); + vi.useRealTimers(); + }); + test("cleans a partial listener registration before retrying initialization", async () => { const firstDeepLinkUnlisten = vi.fn(); const secondDeepLinkUnlisten = vi.fn(); diff --git a/package.json b/package.json index e6d8f5d..2bca96b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "pnpm -r --if-present run build", "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", "test": "pnpm -r --if-present run test", + "test:api-image": "sh apps/api/scripts/test-docker-image.sh", "test:unit": "pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", "test:release": "node --test tests/*.test.mjs", "validate:workflows": "node --test tests/release-workflows.test.mjs", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3574b56..34a89eb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,8 +9,9 @@ export interface ProfileValidationIssue { max: number; } -function characterLength(value: string): number { - return [...value].length; +/** Counts Unicode scalar/code-point values, not UTF-16 units or grapheme clusters. */ +export function codePointLength(value: string): number { + return Array.from(value).length; } /** Runtime validation shared by profile editors and transport boundaries. */ @@ -24,7 +25,7 @@ export function validateSyncProfile( ["targetPath", profile.targetPath, PROFILE_LIMITS.path], ] as const; for (const [field, value, limits] of lengths) { - const length = characterLength(value); + const length = codePointLength(value); if (length < limits.min || length > limits.max) { issues.push({ field, min: limits.min, max: limits.max }); } @@ -33,7 +34,7 @@ export function validateSyncProfile( issues.push({ field: "exclusions", max: PROFILE_LIMITS.exclusions.max }); } profile.exclusions.forEach((pattern, index) => { - const length = characterLength(pattern); + const length = codePointLength(pattern); const limits = PROFILE_LIMITS.exclusions.pattern; if (length < limits.min || length > limits.max) { issues.push({ field: `exclusions[${index}]`, min: limits.min, max: limits.max }); diff --git a/packages/contracts/src/profile-limits.json b/packages/contracts/src/profile-limits.json index 36516e5..d802fbc 100644 --- a/packages/contracts/src/profile-limits.json +++ b/packages/contracts/src/profile-limits.json @@ -1,4 +1,5 @@ { + "lengthUnit": "unicode-code-points", "name": { "min": 1, "max": 80 }, "path": { "min": 1, "max": 4096 }, "exclusions": { "max": 100, "pattern": { "min": 1, "max": 256 } } diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index d7767b1..b906743 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -49,26 +49,29 @@ describe("Rootline contracts", () => { it("exports the exact shared profile limits and runtime validation", () => { expect(contracts.PROFILE_LIMITS).toEqual({ + lengthUnit: "unicode-code-points", name: { min: 1, max: 80 }, path: { min: 1, max: 4096 }, exclusions: { max: 100, pattern: { min: 1, max: 256 } }, }); + expect(contracts.codePointLength("✈️")).toBe(2); + const exactCodePoints = (count: number) => "✈️".repeat(Math.floor(count / 2)) + (count % 2 ? "x" : ""); const valid = { id: "profile", - name: "n".repeat(80), - sourcePath: "/".repeat(4096), - targetPath: "C".repeat(4096), - exclusions: Array.from({ length: 100 }, () => "x".repeat(256)), + name: exactCodePoints(80), + sourcePath: exactCodePoints(4096), + targetPath: exactCodePoints(4096), + exclusions: Array.from({ length: 100 }, () => exactCodePoints(256)), createdAt: "2026-08-15T00:00:00Z", updatedAt: "2026-08-15T00:00:00Z", }; expect(contracts.validateSyncProfile(valid)).toEqual([]); expect(contracts.validateSyncProfile({ ...valid, - name: "n".repeat(81), + name: exactCodePoints(81), sourcePath: "", - targetPath: "t".repeat(4097), - exclusions: [...valid.exclusions, "", "x".repeat(257)], + targetPath: exactCodePoints(4097), + exclusions: [...valid.exclusions, "", exactCodePoints(257)], })).toEqual(expect.arrayContaining([ expect.objectContaining({ field: "name" }), expect.objectContaining({ field: "sourcePath" }), diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index 7c2bb99..35212b6 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -49,6 +49,26 @@ test("production dependency audits fail closed before CI and every release mutat assert.doesNotMatch(lockfile, /js-yaml@5\.2\.1/); }); +test("CI and API release build and health-smoke the production image before publishing it", () => { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + assert.equal(manifest.scripts["test:api-image"], "sh apps/api/scripts/test-docker-image.sh"); + + const smoke = readFileSync(join(root, "apps", "api", "scripts", "test-docker-image.sh"), "utf8"); + for (const expected of [ + "docker build --file apps/api/Dockerfile", + "prisma migrate deploy", + "/healthz", + "JWT_JWKS_PATH", + "trap cleanup EXIT INT TERM", + ]) assert.match(smoke, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + + const ci = workflow("ci.yml"); + const release = workflow("release-api.yml"); + assert.match(ci, /pnpm test:api-image/); + assert.match(release, /pnpm test:api-image/); + assert.ok(release.indexOf("pnpm test:api-image") < release.indexOf("docker/build-push-action")); +}); + test("every third-party action is pinned to a full immutable commit SHA", () => { for (const name of workflows) { const actionReferences = [...workflow(name).matchAll(/uses:\s*([\w.-]+\/[\w.-]+)@([^\s#]+)/g)]; From 8261f8021132e3fa30461fbd9cf04c45022fa6bc Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 11:58:11 +0700 Subject: [PATCH 19/26] fix: fail closed on legacy profile provenance --- .../0007_profile_sync_provenance.sql | 19 +- apps/desktop/src-tauri/src/lib.rs | 174 +++++++-- apps/desktop/src-tauri/tests/native.rs | 341 ++++++++++++++++-- 3 files changed, 478 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql index bbaab5b..0101400 100644 --- a/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql +++ b/apps/desktop/src-tauri/migrations/0007_profile_sync_provenance.sql @@ -1,12 +1,8 @@ -ALTER TABLE mutation_quarantine ADD COLUMN provenance TEXT NOT NULL DEFAULT 'account-bound'; +ALTER TABLE mutation_quarantine ADD COLUMN provenance TEXT NOT NULL DEFAULT 'pre-login'; ALTER TABLE mutation_quarantine ADD COLUMN subject TEXT NOT NULL DEFAULT ''; UPDATE mutation_quarantine -SET subject = COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), ''), - provenance = CASE - WHEN COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = '' THEN 'pre-login' - ELSE 'account-bound' - END; +SET subject = '', provenance = 'pre-login'; CREATE TABLE profile_sync_policy ( profile_id TEXT PRIMARY KEY, @@ -18,3 +14,14 @@ INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) SELECT profile_id, 'unclaimed', '' FROM mutation_quarantine WHERE provenance = 'pre-login' AND profile_id <> ''; + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT id, 'unclaimed', '' +FROM profiles +WHERE COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = ''; + +INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) +SELECT profile_id, 'unclaimed', '' +FROM mutation_outbox +WHERE profile_id <> '' + AND COALESCE((SELECT subject FROM sync_state WHERE singleton = 1), '') = ''; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b3e488f..01fd938 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1518,26 +1518,130 @@ impl Database { )); } let same_owner = owner.as_deref() == Some(subject); - if same_owner && !upload_existing { + let unresolved_claim: i64 = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') + )", + [], + |row| row.get(0), + )?; + if same_owner && !upload_existing && unresolved_claim == 0 { transaction.commit()?; return Ok(()); } if !upload_existing { - transaction.execute("DELETE FROM mutation_outbox", [])?; + if same_owner { + transaction.execute( + "INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'unclaimed', '' FROM mutation_quarantine + WHERE provenance='pre-login' AND profile_id<>''", + [], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='local-only', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + transaction.execute( + "DELETE FROM mutation_outbox + WHERE profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='local-only' AND subject=?1 + )", + [subject], + )?; + } else { + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'local-only', ?1 FROM profiles", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'local-only', ?1 FROM mutation_outbox + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'local-only', ?1 FROM mutation_quarantine + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='local-only', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + transaction.execute("DELETE FROM mutation_outbox", [])?; + } transaction.execute( - "UPDATE profile_sync_policy SET policy='local-only', subject=?1 - WHERE policy='unclaimed'", + "UPDATE mutation_quarantine SET subject=?1 + WHERE provenance='pre-login' AND profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='local-only' AND subject=?1 + )", [subject], )?; } else { + if same_owner { + transaction.execute( + "INSERT OR IGNORE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'unclaimed', '' FROM mutation_quarantine + WHERE provenance='pre-login' AND profile_id<>''", + [], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='consented', subject=?1 + WHERE policy='unclaimed' OR (policy='local-only' AND subject=?1)", + [subject], + )?; + transaction.execute( + "UPDATE mutation_outbox SET preserve_on_epoch_adopt=1 + WHERE profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + )", + [subject], + )?; + } else { + transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT id, 'consented', ?1 FROM profiles", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'consented', ?1 FROM mutation_outbox + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "INSERT OR REPLACE INTO profile_sync_policy(profile_id, policy, subject) + SELECT profile_id, 'consented', ?1 FROM mutation_quarantine + WHERE profile_id<>''", + [subject], + )?; + transaction.execute( + "UPDATE profile_sync_policy SET policy='consented', subject=?1 + WHERE policy='unclaimed'", + [subject], + )?; + } let profiles_to_upload = { let mut statement = transaction.prepare( "SELECT p.id, p.name, p.source_path, p.target_path, p.exclusions_json, p.created_at, p.updated_at FROM profiles p JOIN profile_sync_policy policy ON policy.profile_id=p.id - WHERE policy.policy='unclaimed' - OR (policy.policy='local-only' AND policy.subject=?1)", + WHERE policy.policy='consented' AND policy.subject=?1", )?; let rows = statement.query_map([subject], |row| { let exclusions: String = row.get(4)?; @@ -1553,9 +1657,6 @@ impl Database { })?; rows.collect::, _>>()? }; - if !same_owner { - transaction.execute("UPDATE mutation_outbox SET preserve_on_epoch_adopt=1", [])?; - } for profile in profiles_to_upload { if validate_profile(&profile).is_err() { continue; @@ -1581,8 +1682,11 @@ impl Database { } } transaction.execute( - "UPDATE profile_sync_policy SET policy='consented', subject=?1 - WHERE policy='unclaimed' OR (policy='local-only' AND subject=?1)", + "UPDATE mutation_quarantine SET subject=?1 + WHERE provenance='pre-login' AND profile_id IN ( + SELECT profile_id FROM profile_sync_policy + WHERE policy='consented' AND subject=?1 + )", [subject], )?; } @@ -1593,6 +1697,9 @@ impl Database { session_generation=session_generation + 1, preserve_outbox_on_epoch_adopt=CASE WHEN EXISTS( SELECT 1 FROM mutation_outbox WHERE preserve_on_epoch_adopt=1 + UNION ALL + SELECT 1 FROM profile_sync_policy + WHERE policy='consented' AND subject=?2 ) THEN 1 ELSE preserve_outbox_on_epoch_adopt END, lifecycle_generation=?1 WHERE singleton=1 AND subject=?2", @@ -1768,28 +1875,39 @@ impl Database { expected_lifecycle_generation: Option<&str>, ) -> Result<(serde_json::Value, String), NativeError> { let mut connection = self.connection(); - let claim_required_before_quarantine: i64 = connection.query_row( + let unbound_claim_required_before_quarantine: i64 = connection.query_row( "SELECT EXISTS( SELECT 1 FROM mutation_outbox UNION ALL + SELECT 1 FROM profiles + UNION ALL SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' UNION ALL - SELECT 1 FROM mutation_quarantine WHERE provenance='pre-login' + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') )", [], |row| row.get(0), )?; quarantine_invalid_outbox(&mut connection)?; - let account_claim_required = claim_required_before_quarantine == 1 - || connection.query_row( - "SELECT EXISTS( - SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' - UNION ALL - SELECT 1 FROM mutation_quarantine WHERE provenance='pre-login' - )", - [], - |row| row.get::<_, i64>(0), - )? == 1; + let unresolved_account_claim: i64 = connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM profile_sync_policy WHERE policy='unclaimed' + UNION ALL + SELECT 1 + FROM mutation_quarantine quarantine + LEFT JOIN profile_sync_policy policy ON policy.profile_id=quarantine.profile_id + WHERE quarantine.provenance='pre-login' + AND (policy.profile_id IS NULL OR policy.policy='unclaimed') + )", + [], + |row| row.get(0), + )?; + let unbound_account_claim_required = + unbound_claim_required_before_quarantine == 1 || unresolved_account_claim == 1; let device_id: String = match connection .query_row( "SELECT value FROM settings WHERE key='device_id'", @@ -1851,10 +1969,16 @@ impl Database { } else { match current { Some((owner, epoch, cursor, lifecycle_generation)) if owner == subject => { + if unresolved_account_claim == 1 { + return Err(NativeError::new( + NativeErrorCode::SyncAccountClaimRequired, + "Choose whether this account may upload existing local profiles.", + )); + } (epoch, cursor, lifecycle_generation) } Some((owner, _, _, _)) if owner.is_empty() => { - if account_claim_required { + if unbound_account_claim_required { return Err(NativeError::new( NativeErrorCode::SyncAccountClaimRequired, "Choose whether this account may upload existing local profiles.", @@ -1878,7 +2002,7 @@ impl Database { )) } None => { - if account_claim_required { + if unbound_account_claim_required { return Err(NativeError::new( NativeErrorCode::SyncAccountClaimRequired, "Choose whether this account may upload existing local profiles.", diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index e872fb9..cae6afb 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -67,6 +67,64 @@ fn create_v5_database_with_invalid_profile(path: &std::path::Path, profile: &Pro .unwrap(); } +fn create_v6_database(path: &std::path::Path) -> Connection { + let connection = Connection::open(path).unwrap(); + connection + .execute_batch(&format!( + "PRAGMA foreign_keys = ON; + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); + {} + INSERT INTO schema_migrations(version) VALUES (1); + {} + INSERT INTO schema_migrations(version) VALUES (2); + {} + INSERT INTO schema_migrations(version) VALUES (3); + {} + INSERT INTO schema_migrations(version) VALUES (4); + {} + INSERT INTO schema_migrations(version) VALUES (5); + {} + INSERT INTO schema_migrations(version) VALUES (6);", + include_str!("../migrations/0001_offline_state.sql"), + include_str!("../migrations/0002_account_scoped_sync.sql"), + include_str!("../migrations/0003_sync_session_generation.sql"), + include_str!("../migrations/0004_consented_epoch_adoption.sql"), + include_str!("../migrations/0005_sync_lifecycle_generation.sql"), + include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), + )) + .unwrap(); + connection +} + +fn insert_legacy_profile(connection: &Connection, profile: &Profile) { + connection + .execute( + "INSERT INTO profiles( + id, name, source_path, target_path, exclusions_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + profile.id, + profile.name, + profile.source_path, + profile.target_path, + serde_json::to_string(&profile.exclusions).unwrap(), + profile.created_at, + profile.updated_at + ], + ) + .unwrap(); +} + +fn insert_legacy_sync_state(connection: &Connection, subject: &str) { + connection + .execute( + "INSERT INTO sync_state(singleton, epoch, cursor, subject) + VALUES (1, '00000000-0000-4000-8000-000000000600', '', ?1)", + [subject], + ) + .unwrap(); +} + #[test] fn scans_additively_and_revalidates_before_mkdir() { let source = tempdir().unwrap(); @@ -562,33 +620,14 @@ fn v5_upgrade_quarantines_invalid_outbox_without_removing_local_profiles_and_rec fn v6_upgrade_backfills_existing_prelogin_quarantine_provenance() { let directory = tempdir().unwrap(); let path = directory.path().join("v6-quarantine.sqlite3"); - let connection = Connection::open(&path).unwrap(); + let connection = create_v6_database(&path); connection - .execute_batch(&format!( - "PRAGMA foreign_keys = ON; - CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - {} - INSERT INTO schema_migrations(version) VALUES (1); - {} - INSERT INTO schema_migrations(version) VALUES (2); - {} - INSERT INTO schema_migrations(version) VALUES (3); - {} - INSERT INTO schema_migrations(version) VALUES (4); - {} - INSERT INTO schema_migrations(version) VALUES (5); - {} - INSERT INTO schema_migrations(version) VALUES (6); - INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) VALUES ('00000000-0000-4000-8007-000000000006', 'upsert', - 'legacy-prelogin', 'legacy invalid profile');", - include_str!("../migrations/0001_offline_state.sql"), - include_str!("../migrations/0002_account_scoped_sync.sql"), - include_str!("../migrations/0003_sync_session_generation.sql"), - include_str!("../migrations/0004_consented_epoch_adoption.sql"), - include_str!("../migrations/0005_sync_lifecycle_generation.sql"), - include_str!("../migrations/0006_invalid_outbox_quarantine.sql"), - )) + 'legacy-prelogin', 'legacy invalid profile')", + [], + ) .unwrap(); drop(connection); @@ -603,6 +642,258 @@ fn v6_upgrade_backfills_existing_prelogin_quarantine_provenance() { ); } +#[test] +fn v6_unbound_valid_outbox_keep_local_never_leaks_after_edit_delete_epoch_or_switch() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-valid-outbox-keep.sqlite3"); + let profile = Profile { + id: "v6-private-profile".into(), + name: "Legacy private".into(), + source_path: "/Users/alice/private/v6-source".into(), + target_path: "/Volumes/alice/private/v6-target".into(), + exclusions: vec!["secret-*".into()], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, ""); + connection + .execute( + "INSERT INTO mutation_outbox(mutation_id, kind, payload, occurred_at, profile_id) + VALUES (?1, 'upsert', ?2, ?3, ?4)", + rusqlite::params![ + "00000000-0000-4000-8007-000000000061", + serde_json::to_string(&profile).unwrap(), + profile.updated_at, + profile.id + ], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + let mut edited = profile.clone(); + edited.name = "Private edit".into(); + edited.updated_at = "2026-08-15T00:01:00Z".into(); + database.save_profile(&edited).unwrap(); + let alice = database.hosted_sync_request("alice").unwrap(); + assert_eq!(alice["mutations"], serde_json::json!([])); + assert!(!alice.to_string().contains("/Users/alice/private")); + + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000611", false) + .unwrap(); + assert!(database.pending_outbox().unwrap().is_empty()); + database.disconnect_hosted_account(false).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("bob", false).unwrap(); + edited.name = "Private after switch".into(); + edited.updated_at = "2026-08-15T00:02:00Z".into(); + database.save_profile(&edited).unwrap(); + assert_eq!( + database.hosted_sync_request("bob").unwrap()["mutations"], + serde_json::json!([]) + ); + database.delete_profile(&edited.id).unwrap(); + let after_delete = database.hosted_sync_request("bob").unwrap(); + assert_eq!(after_delete["mutations"], serde_json::json!([])); + assert!(!after_delete.to_string().contains("/Users/alice/private")); +} + +#[test] +fn v6_unbound_retained_profile_without_outbox_requires_a_fresh_claim() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-retained-no-outbox.sqlite3"); + let profile = Profile { + id: "v6-retained-only".into(), + name: "Retained only".into(), + source_path: "/Users/legacy/retained".into(), + target_path: "/Volumes/legacy/retained".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, ""); + insert_legacy_profile(&connection, &profile); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_bound_cloud_state_does_not_prompt_without_ambiguous_quarantine() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-clean.sqlite3"); + let profile = Profile { + id: "v6-bound-clean".into(), + name: "Already synced".into(), + source_path: "/cloud/source".into(), + target_path: "/cloud/target".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &profile); + drop(connection); + + let database = Database::open(&path).unwrap(); + let policy_count: i64 = Connection::open(&path) + .unwrap() + .query_row( + "SELECT COUNT(*) FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(policy_count, 0); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_quarantine_is_ambiguous_even_with_a_populated_subject_and_keep_resolves_it() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-quarantine-keep.sqlite3"); + let profile = Profile { + id: "v6-bound-ambiguous".into(), + name: "Ambiguous local".into(), + source_path: "/Users/alice/ambiguous".into(), + target_path: "/Volumes/alice/ambiguous".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &profile); + connection + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000062', 'upsert', ?1, 'legacy')", + [&profile.id], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + let migrated_policy: (String, String) = Connection::open(&path) + .unwrap() + .query_row( + "SELECT policy, subject FROM profile_sync_policy WHERE profile_id=?1", + [&profile.id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(migrated_policy, ("unclaimed".into(), String::new())); + assert_eq!( + database.quarantined_mutations().unwrap()[0].provenance, + "pre-login" + ); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", false).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); +} + +#[test] +fn v6_bound_quarantine_upload_consent_survives_epoch_until_correction() { + let directory = tempdir().unwrap(); + let path = directory.path().join("v6-bound-quarantine-upload.sqlite3"); + let invalid = Profile { + id: "v6-bound-upload".into(), + name: "n".repeat(81), + source_path: "/Users/alice/upload-consented".into(), + target_path: "/Volumes/alice/upload-consented".into(), + exclusions: vec![], + created_at: "2026-08-15T00:00:00Z".into(), + updated_at: "2026-08-15T00:00:00Z".into(), + }; + let connection = create_v6_database(&path); + insert_legacy_sync_state(&connection, "alice"); + insert_legacy_profile(&connection, &invalid); + connection + .execute( + "INSERT INTO mutation_quarantine(mutation_id, kind, profile_id, reason) + VALUES ('00000000-0000-4000-8007-000000000063', 'upsert', ?1, 'legacy')", + [&invalid.id], + ) + .unwrap(); + drop(connection); + + let database = Database::open(&path).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap_err().code, + NativeErrorCode::SyncAccountClaimRequired + ); + database.claim_hosted_account("alice", true).unwrap(); + assert_eq!( + database.hosted_sync_request("alice").unwrap()["mutations"], + serde_json::json!([]) + ); + assert!(database.preserves_consented_outbox("alice").unwrap()); + database + .accept_account_epoch("alice", "00000000-0000-4000-8000-000000000612", false) + .unwrap(); + + let corrected = Profile { + name: "Corrected after consent".into(), + updated_at: "2026-08-15T00:01:00Z".into(), + ..invalid + }; + database.save_profile(&corrected).unwrap(); + let request = database.hosted_sync_request("alice").unwrap(); + assert_eq!(request["mutations"].as_array().unwrap().len(), 1); + assert_eq!( + request["mutations"][0]["profile"]["sourcePath"], + corrected.source_path + ); +} + #[test] fn keep_local_only_survives_quarantine_correction_epoch_and_account_switch_without_path_leakage() { let directory = tempdir().unwrap(); From b7edaf9cc6a85de64f3afdf461959ba8882db364 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 12:32:10 +0700 Subject: [PATCH 20/26] fix: complete Rootline v2 acceptance gaps --- EXAMPLES.md | 2 +- README.md | 2 +- apps/api/src/sync.dto.ts | 17 +- apps/api/src/sync.service.ts | 150 ++++++++++++-- apps/api/test/sync.e2e.spec.ts | 60 ++++++ apps/desktop/src-tauri/src/lib.rs | 189 ++++++++++++++---- apps/desktop/src-tauri/tests/native.rs | 40 +++- apps/desktop/src/App.tsx | 7 +- apps/desktop/src/components/DiffTree.tsx | 37 ++-- apps/desktop/src/i18n.ts | 8 +- apps/desktop/src/native.ts | 8 + apps/desktop/src/styles.css | 2 +- apps/desktop/src/test/App.test.tsx | 24 ++- docs/configuration.md | 4 +- docs/hosted-profile-sync.md | 2 + packages/cli/README.md | 2 +- packages/cli/src/index.ts | 18 +- packages/cli/src/node-adapter.ts | 68 ++++++- packages/cli/test/cli.test.ts | 7 +- packages/cli/test/node-adapter.test.ts | 14 ++ packages/contracts/src/index.ts | 59 ++++++ packages/contracts/test/contracts.test.ts | 9 +- .../contracts/test/contracts.types.test.ts | 60 ++++++ packages/core/src/index.ts | 72 ++++++- packages/core/test/core.test.ts | 33 +++ 25 files changed, 784 insertions(+), 110 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 92a27e5..582253e 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -24,7 +24,7 @@ Without `--auto`, Rootline asks for confirmation in an interactive terminal. Roo folder-sync ./source ./target --auto --json ``` -`--json` emits one JSON document and never prompts. Combine it with `--auto` to apply a plan in automation. Exit code `0` means success or a deliberate no-op, `1` means an operational failure, and `2` means invalid usage. +`--json` emits one JSON document, never prompts, and is accepted only with `--dry-run` or `--auto`. Combine it with `--auto` to apply a plan in automation. Exit code `0` means success or a deliberate no-op, `1` means an operational failure, and `2` means invalid usage. ## Explicit configuration diff --git a/README.md b/README.md index 2a3e865..bf2eeb1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ folder-sync [options] --json Emit one JSON document and never prompt ``` -`--json` is intended for automation. Exit code `0` means success or a deliberate no-op, `1` means a filesystem/configuration/apply failure, and `2` means invalid command usage. +`--json` is intended for automation and must be paired with `--dry-run` or `--auto`; it never prompts or emits progress/color output. Exit code `0` means success or a deliberate no-op, `1` means a filesystem/configuration/apply failure, and `2` means invalid command usage. ## Documentation diff --git a/apps/api/src/sync.dto.ts b/apps/api/src/sync.dto.ts index 47f8e33..4a394ac 100644 --- a/apps/api/src/sync.dto.ts +++ b/apps/api/src/sync.dto.ts @@ -1,7 +1,7 @@ import { Type } from "class-transformer"; import { PROFILE_LIMITS } from "@rootline/contracts"; import { - ArrayMaxSize, IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested, + ArrayMaxSize, IsArray, IsDateString, IsIn, IsInt, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateNested, } from "class-validator"; import { CodePointLength } from "./code-point-length.validator.js"; @@ -15,22 +15,27 @@ export class SyncProfileDto { @IsString({ each: true }) @CodePointLength(PROFILE_LIMITS.exclusions.pattern.min, PROFILE_LIMITS.exclusions.pattern.max, { each: true }) exclusions!: string[]; - @IsDateString() createdAt!: string; - @IsDateString() updatedAt!: string; + @IsOptional() @IsDateString() createdAt?: string; + @IsOptional() @IsDateString() updatedAt?: string; @IsOptional() @IsIn(["additive"]) syncMode?: "additive"; + @IsOptional() @IsInt() schemaVersion?: number; + @IsOptional() @IsString() @MaxLength(128) revision?: string; + @IsOptional() @IsDateString() deletedAt?: string | null; } export class ProfileMutationDto { @IsUUID() mutationId!: string; - @IsIn(["upsert", "delete"]) kind!: "upsert" | "delete"; - @IsDateString() occurredAt!: string; + @IsOptional() @IsIn(["upsert", "delete"]) kind?: "upsert" | "delete"; + @IsOptional() @IsIn(["upsert", "delete"]) type?: "upsert" | "delete"; + @IsOptional() @IsDateString() occurredAt?: string; @IsOptional() @ValidateNested() @Type(() => SyncProfileDto) profile?: SyncProfileDto; @IsOptional() @IsString() @MinLength(1) @MaxLength(128) profileId?: string; } export class SyncRequestDto { @IsString() @MinLength(1) @MaxLength(128) deviceId!: string; - @IsUUID() epoch!: string; + @IsOptional() @IsUUID() epoch?: string; + @IsOptional() @IsUUID() accountEpoch?: string | null; @IsOptional() @IsString() @MaxLength(512) cursor?: string; @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => ProfileMutationDto) mutations!: ProfileMutationDto[]; } diff --git a/apps/api/src/sync.service.ts b/apps/api/src/sync.service.ts index 7c450bb..615ad36 100644 --- a/apps/api/src/sync.service.ts +++ b/apps/api/src/sync.service.ts @@ -3,11 +3,34 @@ import { Prisma } from "@prisma/client"; import { createHash, randomUUID } from "node:crypto"; import { PrismaService } from "./prisma.service.js"; -import type { DeleteAccountDataDto, ProfileMutationDto, SyncRequestDto } from "./sync.dto.js"; +import type { DeleteAccountDataDto, SyncProfileDto, SyncRequestDto } from "./sync.dto.js"; const RECEIPT_TTL_MS = 90 * 24 * 60 * 60 * 1000; const DELTA_RECORD_LIMIT = 100; const DELTA_BODY_BUDGET = 1024 * 1024; +const PUBLIC_PROFILE_TIMESTAMP = "1970-01-01T00:00:00.000Z"; + +interface NormalizedProfile { + id: string; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + createdAt: string; + updatedAt: string; + syncMode?: "additive"; +} + +type NormalizedMutation = + | { mutationId: string; kind: "upsert"; profile: NormalizedProfile; occurredAt: string; publicRevision?: string } + | { mutationId: string; kind: "delete"; profileId: string; occurredAt: string }; + +interface NormalizedSyncRequest { + deviceId: string; + epoch: string | null; + cursor?: string; + mutations: NormalizedMutation[]; +} function encodeCursor(epoch: string, revision: bigint): string { return Buffer.from(JSON.stringify({ epoch, revision: revision.toString() }), "utf8").toString("base64url"); @@ -38,13 +61,102 @@ function canonical(value: unknown): unknown { return value; } -function mutationHash(mutation: ProfileMutationDto): string { +function mutationHash(mutation: NormalizedMutation): string { return createHash("sha256").update(JSON.stringify(canonical(mutation))).digest("hex"); } -function validateShape(mutation: ProfileMutationDto): void { - if (mutation.kind === "upsert" && (!mutation.profile || mutation.profileId)) throw new BadRequestException("An upsert requires only profile."); - if (mutation.kind === "delete" && (!mutation.profileId || mutation.profile)) throw new BadRequestException("A delete requires only profileId."); +function normalizeProfile(profile: SyncProfileDto, publicContract: boolean): NormalizedProfile { + if (publicContract) { + if (profile.schemaVersion !== 1) throw new BadRequestException({ code: "SCHEMA_UNSUPPORTED" }); + if (profile.revision === undefined || profile.deletedAt !== null || profile.createdAt !== undefined || profile.updatedAt !== undefined || profile.syncMode !== undefined) { + throw new BadRequestException("A public v1 upsert requires revision, deletedAt null, and no internal timestamps."); + } + return { + id: profile.id, + name: profile.name, + sourcePath: profile.sourcePath, + targetPath: profile.targetPath, + exclusions: profile.exclusions, + createdAt: PUBLIC_PROFILE_TIMESTAMP, + updatedAt: PUBLIC_PROFILE_TIMESTAMP, + syncMode: "additive", + }; + } + if (!profile.createdAt || !profile.updatedAt || profile.schemaVersion !== undefined || profile.revision !== undefined || profile.deletedAt !== undefined) { + throw new BadRequestException("An internal profile requires timestamps and cannot mix public v1 fields."); + } + return { + id: profile.id, + name: profile.name, + sourcePath: profile.sourcePath, + targetPath: profile.targetPath, + exclusions: profile.exclusions, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + ...(profile.syncMode ? { syncMode: profile.syncMode } : {}), + }; +} + +function normalizeRequest(dto: SyncRequestDto): NormalizedSyncRequest { + const publicContract = dto.accountEpoch !== undefined || dto.mutations.some((mutation) => mutation.type !== undefined); + if (publicContract && dto.epoch !== undefined) throw new BadRequestException("Public and internal epoch fields cannot be mixed."); + if (!publicContract && (!dto.epoch || dto.accountEpoch !== undefined)) throw new BadRequestException("An epoch is required."); + const mutations = dto.mutations.map((mutation): NormalizedMutation => { + if (publicContract) { + if (!mutation.type || mutation.kind !== undefined || mutation.occurredAt !== undefined) { + throw new BadRequestException("A public mutation requires only its type discriminant."); + } + if (mutation.type === "upsert") { + if (!mutation.profile || mutation.profileId || mutation.profile.revision === undefined) { + throw new BadRequestException("A public upsert requires only a revisioned profile."); + } + return { + mutationId: mutation.mutationId, + kind: "upsert", + profile: normalizeProfile(mutation.profile, true), + occurredAt: PUBLIC_PROFILE_TIMESTAMP, + publicRevision: mutation.profile.revision, + }; + } + if (!mutation.profileId || mutation.profile) throw new BadRequestException("A delete requires only profileId."); + return { mutationId: mutation.mutationId, kind: "delete", profileId: mutation.profileId, occurredAt: PUBLIC_PROFILE_TIMESTAMP }; + } + if (!mutation.kind || mutation.type !== undefined || !mutation.occurredAt) { + throw new BadRequestException("An internal mutation requires kind and occurredAt."); + } + if (mutation.kind === "upsert") { + if (!mutation.profile || mutation.profileId) throw new BadRequestException("An upsert requires only profile."); + return { mutationId: mutation.mutationId, kind: "upsert", profile: normalizeProfile(mutation.profile, false), occurredAt: mutation.occurredAt }; + } + if (!mutation.profileId || mutation.profile) throw new BadRequestException("A delete requires only profileId."); + return { mutationId: mutation.mutationId, kind: "delete", profileId: mutation.profileId, occurredAt: mutation.occurredAt }; + }); + return { + deviceId: dto.deviceId, + epoch: publicContract ? dto.accountEpoch ?? null : dto.epoch!, + ...(dto.cursor === undefined ? {} : { cursor: dto.cursor }), + mutations, + }; +} + +function publicProfile(record: Prisma.JsonValue): Record | null { + if (typeof record !== "object" || record === null || Array.isArray(record)) return null; + const value = record as Record; + const profile = value.profile; + if (typeof profile !== "object" || profile === null || Array.isArray(profile)) return null; + const source = profile as Record; + if (typeof source.id !== "string" || typeof source.name !== "string" || typeof source.sourcePath !== "string" + || typeof source.targetPath !== "string" || !Array.isArray(source.exclusions) || typeof value.revision !== "number") return null; + return { + id: source.id, + schemaVersion: 1, + name: source.name, + sourcePath: source.sourcePath, + targetPath: source.targetPath, + exclusions: source.exclusions, + revision: String(value.revision), + deletedAt: value.kind === "tombstone" && typeof value.deletedAt === "string" ? value.deletedAt : null, + }; } @Injectable() @@ -52,19 +164,19 @@ export class SyncService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async sync(subject: string, dto: SyncRequestDto) { - dto.mutations.forEach(validateShape); - const requestedRevision = decodeCursor(dto.cursor, dto.epoch); + const request = normalizeRequest(dto); return this.prisma.$transaction(async (tx) => { - await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: dto.epoch } }); + await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: request.epoch ?? randomUUID() } }); await tx.$queryRaw`SELECT "subject" FROM "user_sync_state" WHERE "subject" = ${subject} FOR UPDATE`; const state = await tx.userSyncState.findUniqueOrThrow({ where: { subject } }); - if (state.epoch !== dto.epoch) throw new ConflictException({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch: state.epoch }); + if (request.epoch !== null && state.epoch !== request.epoch) throw new ConflictException({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch: state.epoch }); + const requestedRevision = decodeCursor(request.cursor, state.epoch); if (requestedRevision > state.revision) throw new BadRequestException("Cursor revision is ahead of the server."); await tx.mutationReceipt.deleteMany({ where: { expiresAt: { lte: new Date() } } }); let revision = state.revision; const receipts: Array<{ mutationId: string; revision: number }> = []; - for (const mutation of dto.mutations) { + for (const mutation of request.mutations) { const hash = mutationHash(mutation); const prior = await tx.mutationDedup.findUnique({ where: { subject_mutationId: { subject, mutationId: mutation.mutationId } } }); if (prior) { @@ -74,19 +186,28 @@ export class SyncService { } revision += 1n; const committedAt = new Date(); + const existing = mutation.kind === "delete" + ? await tx.profileRecord.findUnique({ where: { subject_profileId: { subject, profileId: mutation.profileId } } }) + : null; + const retainedProfile = existing?.profile && typeof existing.profile === "object" && !Array.isArray(existing.profile) + ? existing.profile + : null; const record = mutation.kind === "upsert" ? { kind: "profile", profile: mutation.profile!, revision: Number(revision) } - : { kind: "tombstone", profileId: mutation.profileId!, deletedAt: committedAt.toISOString(), revision: Number(revision) }; + : { + kind: "tombstone", profileId: mutation.profileId!, deletedAt: committedAt.toISOString(), revision: Number(revision), + ...(retainedProfile ? { profile: retainedProfile } : {}), + }; await tx.profileRecord.upsert({ where: { subject_profileId: { subject, profileId: mutation.kind === "upsert" ? mutation.profile!.id : mutation.profileId! } }, create: { subject, profileId: mutation.kind === "upsert" ? mutation.profile!.id : mutation.profileId!, kind: mutation.kind === "upsert" ? "profile" : "tombstone", - profile: mutation.kind === "upsert" ? json(mutation.profile) : Prisma.JsonNull, + profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, deletedAt: mutation.kind === "delete" ? committedAt : null, revision, committedAt, }, update: { kind: mutation.kind === "upsert" ? "profile" : "tombstone", - profile: mutation.kind === "upsert" ? json(mutation.profile) : Prisma.JsonNull, + profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, deletedAt: mutation.kind === "delete" ? committedAt : null, revision, committedAt, }, }); @@ -116,10 +237,13 @@ export class SyncService { } return { epoch: state.epoch, + accountEpoch: state.epoch, cursor: encodeCursor(state.epoch, cursorRevision), hasMore: cursorRevision < revision, records, receipts, + acknowledgedMutationIds: receipts.map((receipt) => receipt.mutationId), + profiles: records.map(publicProfile).filter((profile): profile is Record => profile !== null), }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index e4055fb..8a9d7b5 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -130,6 +130,66 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { expect(isolated.body.records).toEqual([]); }); + test("accepts and returns the documented public v1 sync contract alongside the paginated protocol", async () => { + const auth = await token("public-contract-user"); + const mutationId = "00000000-0000-4000-8000-000000000205"; + const profile = { + id: "public-profile", + schemaVersion: 1, + name: "Public profile", + sourcePath: "/Users/public/source", + targetPath: "D:\\public-target", + exclusions: ["generated/**/cache"], + revision: "0", + deletedAt: null, + }; + const created = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: null, + cursor: "", + mutations: [{ mutationId, type: "upsert", profile }], + }).expect(200); + + expect(created.body).toMatchObject({ + accountEpoch: expect.any(String), + acknowledgedMutationIds: [mutationId], + profiles: [{ ...profile, revision: "1" }], + }); + expect(created.body).toMatchObject({ epoch: created.body.accountEpoch, receipts: [{ mutationId }] }); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ mutationId, type: "upsert", profile: { ...profile, revision: "different-payload" } }], + }).expect(409); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ + mutationId: "00000000-0000-4000-8000-000000000207", + type: "upsert", + profile: { ...profile, syncMode: "additive" }, + }], + }).expect(400); + + const deleteMutationId = "00000000-0000-4000-8000-000000000206"; + const deleted = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ mutationId: deleteMutationId, type: "delete", profileId: profile.id }], + }).expect(200); + expect(deleted.body.acknowledgedMutationIds).toEqual([deleteMutationId]); + expect(deleted.body.profiles).toEqual([ + expect.objectContaining({ ...profile, revision: "2", deletedAt: expect.any(String) }), + ]); + }); + test("keeps mutation idempotency for the account epoch after the 90-day receipt expires", async () => { const subject = "durable-dedup-user"; const auth = await token(subject); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 01fd938..39c064b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -191,6 +191,22 @@ pub enum DirectoryStatus { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DiffStatus { + Missing, + Exists, + Excluded, + Unreadable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiffEntry { + pub relative_path: String, + pub status: DiffStatus, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirectoryResult { @@ -211,6 +227,7 @@ pub struct ScanPlan { pub target_case_sensitive: bool, pub plan_fingerprint: String, pub missing: Vec, + pub diff_entries: Vec, pub skipped_links: Vec, } @@ -229,6 +246,8 @@ struct Snapshot { entries: Vec, fingerprint: String, skipped_links: Vec, + excluded: Vec, + unreadable: Vec, } fn absolute(path: &Path) -> Result { @@ -547,21 +566,44 @@ fn normalize_relative(path: &Path) -> String { fn glob_segment_matches(value: &str, pattern: &str) -> bool { let value: Vec<_> = value.chars().collect(); let pattern: Vec<_> = pattern.chars().collect(); - let mut matches = vec![false; value.len() + 1]; - matches[0] = true; - for token in pattern { - if token == '*' { - for index in 1..=value.len() { - matches[index] = matches[index] || matches[index - 1]; - } - } else { - for index in (1..=value.len()).rev() { - matches[index] = matches[index - 1] && value[index - 1] == token; - } - matches[0] = false; + fn visit( + value: &[char], + pattern: &[char], + value_index: usize, + pattern_index: usize, + memo: &mut HashMap<(usize, usize), bool>, + ) -> bool { + if let Some(result) = memo.get(&(value_index, pattern_index)) { + return *result; } + let result = if pattern_index == pattern.len() { + value_index == value.len() + } else if pattern[pattern_index] == '*' && pattern.get(pattern_index + 1) == Some(&'*') { + let after_globstar = pattern_index + 2; + let skips_empty_segment = pattern.get(after_globstar) == Some(&'/') + && visit(value, pattern, value_index, after_globstar + 1, memo); + skips_empty_segment + || visit(value, pattern, value_index, after_globstar, memo) + || (value_index < value.len() + && visit(value, pattern, value_index + 1, pattern_index, memo)) + } else if pattern[pattern_index] == '*' { + visit(value, pattern, value_index, pattern_index + 1, memo) + || (value_index < value.len() + && value[value_index] != '/' + && visit(value, pattern, value_index + 1, pattern_index, memo)) + } else if pattern[pattern_index] == '?' { + value_index < value.len() + && value[value_index] != '/' + && visit(value, pattern, value_index + 1, pattern_index + 1, memo) + } else { + value_index < value.len() + && value[value_index] == pattern[pattern_index] + && visit(value, pattern, value_index + 1, pattern_index + 1, memo) + }; + memo.insert((value_index, pattern_index), result); + result } - matches[value.len()] + visit(&value, &pattern, 0, 0, &mut HashMap::new()) } fn excluded(relative: &str, patterns: &[String]) -> bool { @@ -597,20 +639,30 @@ fn scan_root( ) -> Result { let mut entries = Vec::new(); let mut skipped_links = Vec::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(current) = pending.pop() { + let mut excluded_entries = Vec::new(); + let mut unreadable = Vec::new(); + let mut pending = vec![(root.to_path_buf(), String::new())]; + while let Some((current, current_relative)) = pending.pop() { token.check()?; if current.join(".ignore").exists() { continue; } - let mut children = fs::read_dir(¤t) - .map_err(|error| { - NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), ¤t) - })? - .collect::, _>>() - .map_err(|error| { - NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), ¤t) - })?; + let mut children = match fs::read_dir(¤t) + .and_then(|entries| entries.collect::, _>>()) + { + Ok(children) => children, + Err(_error) if !current_relative.is_empty() => { + unreadable.push(current_relative); + continue; + } + Err(error) => { + return Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + ¤t, + )); + } + }; children.sort_by_key(|entry| entry.file_name()); for child in children.into_iter().rev() { token.check()?; @@ -618,29 +670,59 @@ fn scan_root( let relative = normalize_relative(path.strip_prefix(root).expect("entry is below root")); if excluded(&relative, exclusions) { + excluded_entries.push(relative); continue; } - let metadata = fs::symlink_metadata(&path).map_err(|error| { - NativeError::at(NativeErrorCode::UnreadablePath, error.to_string(), &path) - })?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(_) => { + unreadable.push(relative); + continue; + } + }; if is_link_or_junction(&metadata) { skipped_links.push(relative); } else if metadata.is_dir() { - entries.push(relative); - pending.push(path); + entries.push(relative.clone()); + pending.push((path, relative)); } } } entries.sort(); skipped_links.sort(); - let fingerprint = fingerprint(&entries); + excluded_entries.sort(); + unreadable.sort(); + let mut fingerprint_values = entries.clone(); + fingerprint_values.extend( + excluded_entries + .iter() + .map(|path| format!("excluded:{path}")), + ); + fingerprint_values.extend(unreadable.iter().map(|path| format!("unreadable:{path}"))); + fingerprint_values.extend(skipped_links.iter().map(|path| format!("linked:{path}"))); + let fingerprint = fingerprint(&fingerprint_values); Ok(Snapshot { entries, fingerprint, skipped_links, + excluded: excluded_entries, + unreadable, }) } +fn contains_path_or_ancestor(paths: &HashSet, relative: &str) -> bool { + let mut current = relative; + loop { + if paths.contains(current) { + return true; + } + let Some(separator) = current.rfind('/') else { + return false; + }; + current = ¤t[..separator]; + } +} + pub fn scan_plan( request: &ScanRequest, token: &CancellationToken, @@ -664,11 +746,44 @@ pub fn scan_plan( .iter() .map(|entry| comparable(entry)) .collect(); - let missing: Vec<_> = source_snapshot - .entries + let source_unreadable: HashSet<_> = source_snapshot.unreadable.iter().cloned().collect(); + let target_unreadable: HashSet<_> = target_snapshot + .unreadable .iter() - .filter(|entry| !target_entries.contains(&comparable(entry))) - .cloned() + .map(|entry| comparable(entry)) + .collect(); + let mut statuses = HashMap::new(); + for entry in &source_snapshot.entries { + let comparable_entry = comparable(entry); + let status = if contains_path_or_ancestor(&source_unreadable, entry) + || contains_path_or_ancestor(&target_unreadable, &comparable_entry) + { + DiffStatus::Unreadable + } else if target_entries.contains(&comparable_entry) { + DiffStatus::Exists + } else { + DiffStatus::Missing + }; + statuses.insert(entry.clone(), status); + } + for entry in &source_snapshot.excluded { + statuses.insert(entry.clone(), DiffStatus::Excluded); + } + for entry in &source_snapshot.unreadable { + statuses.insert(entry.clone(), DiffStatus::Unreadable); + } + let mut diff_entries: Vec<_> = statuses + .into_iter() + .map(|(relative_path, status)| DiffEntry { + relative_path, + status, + }) + .collect(); + diff_entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let missing: Vec<_> = diff_entries + .iter() + .filter(|entry| entry.status == DiffStatus::Missing) + .map(|entry| entry.relative_path.clone()) .collect(); let mut plan_values = vec![ source.to_string_lossy().into_owned(), @@ -681,7 +796,11 @@ pub fn scan_plan( "case-insensitive".into() }, ]; - plan_values.extend(missing.iter().cloned()); + plan_values.extend( + diff_entries + .iter() + .map(|entry| format!("{:?}:{}", entry.status, entry.relative_path)), + ); Ok(ScanPlan { operation_id: request.operation_id.clone(), source_root: source, @@ -691,6 +810,7 @@ pub fn scan_plan( target_case_sensitive, plan_fingerprint: fingerprint(&plan_values), missing, + diff_entries, skipped_links: source_snapshot.skipped_links, }) } @@ -750,6 +870,7 @@ fn apply_plan_with_observer( || current.target_fingerprint != plan.target_fingerprint || current.plan_fingerprint != plan.plan_fingerprint || current.missing != plan.missing + || current.diff_entries != plan.diff_entries { return Err(NativeError::new( NativeErrorCode::StalePlan, diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index cae6afb..2c76ec6 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -2,7 +2,8 @@ use std::fs; use rootline_desktop::{ apply_plan, detect_case_sensitive, random_vault_password, resolve_existing_vault_password, - scan_plan, CancellationToken, Database, DirectoryStatus, NativeErrorCode, Profile, ScanRequest, + scan_plan, CancellationToken, Database, DiffStatus, DirectoryStatus, NativeErrorCode, Profile, + ScanRequest, }; use rusqlite::Connection; use tempfile::tempdir; @@ -158,6 +159,43 @@ fn scans_additively_and_revalidates_before_mkdir() { assert_eq!(error.code, NativeErrorCode::StalePlan); } +#[cfg(unix)] +#[test] +fn reports_missing_exists_excluded_and_unreadable_diff_states_with_full_globs() { + use std::os::unix::fs::PermissionsExt; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + fs::create_dir_all(source.path().join("docs/api")).unwrap(); + fs::create_dir(source.path().join("shared")).unwrap(); + fs::create_dir_all(source.path().join("generated/deep/cache")).unwrap(); + fs::create_dir(source.path().join("app1.log")).unwrap(); + fs::create_dir(source.path().join("app10.log")).unwrap(); + let locked = source.path().join("locked"); + fs::create_dir(&locked).unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + fs::create_dir(target.path().join("shared")).unwrap(); + + let mut request = request(source.path(), target.path()); + request.exclusions = vec!["generated/**/cache".into(), "app?.log".into()]; + let result = scan_plan(&request, &CancellationToken::default()); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap(); + let plan = result.unwrap(); + + let status = |path: &str| { + plan.diff_entries + .iter() + .find(|entry| entry.relative_path == path) + .map(|entry| entry.status) + }; + assert_eq!(status("docs"), Some(DiffStatus::Missing)); + assert_eq!(status("shared"), Some(DiffStatus::Exists)); + assert_eq!(status("generated/deep/cache"), Some(DiffStatus::Excluded)); + assert_eq!(status("app1.log"), Some(DiffStatus::Excluded)); + assert_eq!(status("app10.log"), Some(DiffStatus::Missing)); + assert_eq!(status("locked"), Some(DiffStatus::Unreadable)); +} + #[test] fn binds_a_plan_to_its_canonical_roots_even_when_snapshots_match() { let source = tempdir().unwrap(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7d9bc02..7cac3fa 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -462,14 +462,15 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina {plan.skippedLinks.length ?

{text.skipped(plan.skippedLinks.length)}

: null} {plan.missing.length === 0 ? (

{text.empty}

{text.emptyBody}

- ) : ( + ) : null} + {plan.diffEntries.length ? ( - )} + ) : null}
{plan.missing.length ? : null} diff --git a/apps/desktop/src/components/DiffTree.tsx b/apps/desktop/src/components/DiffTree.tsx index b7c23d0..081cae2 100644 --- a/apps/desktop/src/components/DiffTree.tsx +++ b/apps/desktop/src/components/DiffTree.tsx @@ -1,22 +1,23 @@ import { memo, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { copy } from "../i18n"; +import type { DiffEntry, DiffStatus } from "../native"; export interface DiffTreeLabels { search: string; all: string; selected: string; clear: string; - selectAll: string; + selectAllMissing: string; folderDifferences: string; folderFilter: string; - missingFolders: string; + status: Record; visibleCount: (count: number) => string; selectedCount: (count: number) => string; } interface DiffTreeProps { - entries: readonly string[]; + entries: readonly DiffEntry[]; selected: ReadonlySet; onSelectionChange: (selection: Set) => void; labels?: DiffTreeLabels; @@ -67,22 +68,25 @@ function closeOverParents(entries: readonly string[], candidates: ReadonlySet entries.map((entry) => entry.relativePath), [entries]); + const entryByPath = useMemo(() => new Map(entries.map((entry) => [entry.relativePath, entry])), [entries]); + const missingPaths = useMemo(() => entries.filter((entry) => entry.status === "missing").map((entry) => entry.relativePath), [entries]); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase()); const [filter, setFilter] = useState<"all" | "selected">("all"); - const parents = useMemo(() => parentPaths(entries), [entries]); + const parents = useMemo(() => parentPaths(paths), [paths]); const [expanded, setExpanded] = useState>(() => new Set(parents)); const [scrollTop, setScrollTop] = useState(0); - const [activeEntry, setActiveEntry] = useState(entries[0] ?? ""); + const [activeEntry, setActiveEntry] = useState(paths[0] ?? ""); const viewport = useRef(null); const itemRefs = useRef(new Map()); const shouldFocusActive = useRef(false); - const visible = useMemo(() => entries.filter((entry) => { + const visible = useMemo(() => paths.filter((entry) => { if (filter === "selected" && !selected.has(entry)) return false; if (deferredQuery && !entry.toLocaleLowerCase().includes(deferredQuery)) return false; return visibleUnderExpansion(entry, expanded, parents); - }), [deferredQuery, entries, expanded, filter, parents, selected]); + }), [deferredQuery, expanded, filter, parents, paths, selected]); useEffect(() => { if (!visible.includes(activeEntry)) setActiveEntry(visible[0] ?? ""); @@ -112,14 +116,15 @@ export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionC }; const toggleSelection = (entry: string): void => { - const descendants = entries.filter((candidate) => candidate === entry || candidate.startsWith(`${entry}/`)); + if (entryByPath.get(entry)?.status !== "missing") return; + const descendants = missingPaths.filter((candidate) => candidate === entry || candidate.startsWith(`${entry}/`)); const next = new Set(selected); const shouldSelect = descendants.some((candidate) => !next.has(candidate)); for (const descendant of descendants) { if (shouldSelect) next.add(descendant); else next.delete(descendant); } - onSelectionChange(closeOverParents(entries, next)); + onSelectionChange(closeOverParents(missingPaths, next)); }; const toggleExpanded = (entry: string, force?: boolean): void => { @@ -171,19 +176,21 @@ export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionC
- +
{labels.visibleCount(visible.length)} {labels.selectedCount(selected.size)}
-
setScrollTop(event.currentTarget.scrollTop)}> +
setScrollTop(event.currentTarget.scrollTop)}>
{windowed.map((entry, offset) => { const index = start + offset; const isParent = parents.has(entry); const isExpanded = expanded.has(entry); const isSelected = selected.has(entry); + const diff = entryByPath.get(entry)!; + const statusId = `diff-status-${index}`; return (
{ if (node) itemRefs.current.set(entry, node); else itemRefs.current.delete(entry); }} @@ -191,19 +198,21 @@ export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionC role="treeitem" aria-label={entry} aria-level={depthOf(entry)} - aria-selected={isSelected} + aria-selected={diff.status === "missing" ? isSelected : undefined} aria-expanded={isParent ? isExpanded : undefined} + aria-describedby={statusId} tabIndex={activeEntry === entry ? 0 : -1} key={entry} - onClick={() => { setActiveEntry(entry); toggleSelection(entry); }} + onClick={() => { setActiveEntry(entry); if (diff.status === "missing") toggleSelection(entry); else if (isParent) toggleExpanded(entry); }} onFocus={() => setActiveEntry(entry)} onKeyDown={(event) => onTreeKeyDown(event, entry)} style={{ top: `${index * ROW_HEIGHT}px`, paddingInlineStart: `${14 + (depthOf(entry) - 1) * 22}px` }} > - + {diff.status === "missing" ? :
); })} diff --git a/apps/desktop/src/i18n.ts b/apps/desktop/src/i18n.ts index 46b104a..fa47a5e 100644 --- a/apps/desktop/src/i18n.ts +++ b/apps/desktop/src/i18n.ts @@ -147,10 +147,10 @@ export const copy = { all: "All", selected: "Selected", clear: "Clear selection", - selectAll: "Select all", + selectAllMissing: "Select all missing", folderDifferences: "Folder differences", folderFilter: "Folder filter", - missingFolders: "Missing folders", + status: { missing: "Missing", exists: "Exists", excluded: "Excluded", unreadable: "Unreadable" }, visibleCount: (count: number) => `${count.toLocaleString()} folders`, selectedCount: (count: number) => `${count.toLocaleString()} selected`, }, @@ -222,10 +222,10 @@ export const copy = { all: "Tất cả", selected: "Đã chọn", clear: "Bỏ chọn", - selectAll: "Chọn tất cả", + selectAllMissing: "Chọn tất cả thư mục còn thiếu", folderDifferences: "Các thư mục khác biệt", folderFilter: "Bộ lọc thư mục", - missingFolders: "Thư mục còn thiếu", + status: { missing: "Còn thiếu", exists: "Đã tồn tại", excluded: "Đã loại trừ", unreadable: "Không thể đọc" }, visibleCount: (count: number) => `${count.toLocaleString()} thư mục`, selectedCount: (count: number) => `Đã chọn ${count.toLocaleString()}`, }, diff --git a/apps/desktop/src/native.ts b/apps/desktop/src/native.ts index 4b91a4e..c2f99ea 100644 --- a/apps/desktop/src/native.ts +++ b/apps/desktop/src/native.ts @@ -26,9 +26,17 @@ export interface ScanPlan { targetCaseSensitive: boolean; planFingerprint: string; missing: string[]; + diffEntries: DiffEntry[]; skippedLinks: string[]; } +export type DiffStatus = "missing" | "exists" | "excluded" | "unreadable"; + +export interface DiffEntry { + relativePath: string; + status: DiffStatus; +} + export type DirectoryStatus = "created" | "already-exists" | "failed"; export interface ApplyResult { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d5ba215..ea3f8db 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -81,7 +81,7 @@ h1 { max-width: 760px; margin: 0; color: var(--graphite-950); font-size: clamp(3 @keyframes rootline-scan { from { transform: translateX(0); } to { transform: translateX(178px); } } .review-stage h1 { font-size: clamp(34px, 4.8vw, 58px); }.review-stage .lede { margin-bottom: 24px; }.safety-note { display: inline-block; margin: 0 0 16px; padding: 6px 9px; color: #6d5623; background: #d69b3b1c; font: 600 10px/1.3 ui-monospace, monospace; } -.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field:focus-within { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 2px; }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #20272a09; content-visibility: auto; cursor: pointer; }.tree-row:hover, .tree-row[aria-selected="true"] { background: #23bac70a; }.tree-row:focus-visible { z-index: 1; outline-offset: -3px; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; color: var(--cyan-600); font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.selection-box { width: 15px; height: 15px; display: grid; place-items: center; flex: 0 0 15px; border: 1px solid #7f8a85; border-radius: 3px; color: var(--graphite-950); background: var(--fog-50); font: 700 10px/1 sans-serif; }.tree-row[aria-selected="true"] .selection-box { border-color: var(--cyan-600); background: var(--cyan-500); }.folder-glyph { width: 14px; height: 10px; flex: 0 0 14px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } +.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field:focus-within { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 2px; }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #20272a09; content-visibility: auto; cursor: pointer; }.tree-row:hover, .tree-row[aria-selected="true"] { background: #23bac70a; }.tree-row:focus-visible { z-index: 1; outline-offset: -3px; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; color: var(--cyan-600); font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.selection-box { width: 15px; height: 15px; display: grid; place-items: center; flex: 0 0 15px; border: 1px solid #7f8a85; border-radius: 3px; color: var(--graphite-950); background: var(--fog-50); font: 700 10px/1 sans-serif; }.tree-row[aria-selected="true"] .selection-box { border-color: var(--cyan-600); background: var(--cyan-500); }.status-spacer { width: 15px; flex: 0 0 15px; }.folder-glyph { width: 14px; height: 10px; flex: 0 0 14px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { min-width: 0; overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.diff-status { flex: 0 0 auto; margin-left: auto; margin-right: 14px; padding: 3px 6px; border-radius: 999px; color: #59635f; background: var(--fog-100); font: 700 9px/1.2 ui-monospace, monospace; }.diff-status.status-missing { color: #087d88; background: #23bac71a; }.diff-status.status-exists { color: #42604d; background: #587b641c; }.diff-status.status-excluded { color: #6d5623; background: #d69b3b1c; }.diff-status.status-unreadable { color: var(--danger); background: #c7584f1c; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; margin-bottom: 16px; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } .result-stage { max-width: 760px; padding-top: 60px; }.result-mark { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 34px; border-radius: 50%; color: var(--fog-50); background: var(--moss-500); font-size: 26px; }.result-mark.cancelled { background: var(--amber-500); }.result-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 35px; border-block: 1px solid var(--fog-300); }.result-summary div { display: flex; align-items: baseline; gap: 9px; padding: 20px 14px; }.result-summary div + div { border-left: 1px solid var(--fog-300); }.result-summary strong { font: 500 30px/1 ui-monospace, monospace; }.result-summary span { color: #69736f; font-size: 11px; }.result-details { max-height: 260px; margin: 18px 0 0; padding: 0; overflow: auto; list-style: none; border: 1px solid var(--fog-300); border-radius: 8px; }.result-details li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 16px; padding: 10px 12px; }.result-details li + li { border-top: 1px solid var(--fog-100); }.result-path { overflow: hidden; font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.result-status { color: #69736f; font-size: 11px; }.result-details small { grid-column: 1 / -1; color: var(--danger); }.failure-action { padding-left: 12px; color: var(--danger); border-left: 3px solid var(--danger); font-size: 12px; } diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx index 6da9c43..3f4d03a 100644 --- a/apps/desktop/src/test/App.test.tsx +++ b/apps/desktop/src/test/App.test.tsx @@ -17,6 +17,15 @@ const plan: ScanPlan = { targetCaseSensitive: false, planFingerprint: "plan-fp", missing: ["docs", "docs/api", "src", "src/components"], + diffEntries: [ + { relativePath: "docs", status: "missing" }, + { relativePath: "docs/api", status: "missing" }, + { relativePath: "existing", status: "exists" }, + { relativePath: "private", status: "excluded" }, + { relativePath: "locked", status: "unreadable" }, + { relativePath: "src", status: "missing" }, + { relativePath: "src/components", status: "missing" }, + ], skippedLinks: [], }; @@ -188,7 +197,11 @@ describe("Rootline desktop workflow", () => { }} />); await user.click(screen.getByRole("button", { name: "Scan differences" })); await screen.findByRole("heading", { name: "Review 4 missing folders" }); - expect(screen.getByRole("tree", { name: "Missing folders" })).toHaveAttribute("aria-multiselectable", "true"); + expect(screen.getByRole("tree", { name: "Folder differences" })).toHaveAttribute("aria-multiselectable", "true"); + expect(screen.getByRole("treeitem", { name: "existing" })).toHaveTextContent("Exists"); + expect(screen.getByRole("treeitem", { name: "private" })).toHaveTextContent("Excluded"); + expect(screen.getByRole("treeitem", { name: "locked" })).toHaveTextContent("Unreadable"); + expect(screen.getByRole("button", { name: "Select all missing" })).toBeInTheDocument(); const results = await axe.run(container); expect(results.violations).toEqual([]); }); @@ -321,23 +334,24 @@ describe("Rootline desktop workflow", () => { describe("DiffTree virtualization", () => { test("renders a bounded window for a 50,000-folder fixture and keeps subtree selection", async () => { const user = userEvent.setup(); - const entries = Array.from({ length: 50_000 }, (_, index) => `root/group-${Math.floor(index / 100)}/folder-${index}`); + const paths = Array.from({ length: 50_000 }, (_, index) => `root/group-${Math.floor(index / 100)}/folder-${index}`); + const entries = paths.map((relativePath) => ({ relativePath, status: "missing" as const })); const onSelectionChange = vi.fn(); const { container } = render( - , + , ); expect(screen.getByRole("tree")).not.toHaveAttribute("aria-rowcount"); expect(container.querySelectorAll('[role="treeitem"]').length).toBeLessThan(80); await user.type(screen.getByRole("searchbox", { name: "Search folders" }), "folder-49999"); - expect(await screen.findByRole("treeitem", { name: entries[49_999]! })).toBeInTheDocument(); + expect(await screen.findByRole("treeitem", { name: paths[49_999]! })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Clear selection" })); expect(onSelectionChange).toHaveBeenLastCalledWith(new Set()); }); test("uses named treeitems with roving keyboard navigation and parent dependency selection", async () => { const user = userEvent.setup(); - const entries = ["docs", "docs/api", "docs/api/v2", "src"]; + const entries = ["docs", "docs/api", "docs/api/v2", "src"].map((relativePath) => ({ relativePath, status: "missing" as const })); const onSelectionChange = vi.fn(); const view = render(); const docs = screen.getByRole("treeitem", { name: "docs" }); diff --git a/docs/configuration.md b/docs/configuration.md index 7018298..59b99f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,9 +18,9 @@ Configuration resolution is deliberately narrow: an explicit `--config` path win } ``` -`defaultExclusions` replaces the built-in list when present. `customExclusions` is appended. Patterns match path segments and Rootline prunes the complete matching subtree. `targetCaseSensitive` defaults to `false` on macOS/Windows and `true` elsewhere; set it only when the target filesystem's actual semantics differ. +`defaultExclusions` replaces the built-in list when present. `customExclusions` is appended. Patterns without `/` match a basename at any depth; patterns with `/` match a complete POSIX relative path. `*`, `?`, and `**` are supported and a match prunes the complete subtree. Rootline detects the target filesystem's case behavior without writing probe files; `targetCaseSensitive` is an explicit override for unusual or unavailable platform metadata. -`--dry-run` never creates a missing target. `--json` never prompts. Use `--auto --json` for non-interactive application, and treat exit codes `1` and `2` as failures. +`--dry-run` never creates a missing target. `--json` is valid only with `--dry-run` or `--auto` and never prompts. Use `--auto --json` for non-interactive application, and treat exit codes `1` and `2` as failures. ## Desktop profiles diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 35e1c90..6d90a16 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -69,6 +69,8 @@ The stable deployment must provide TLS termination for the API, TLS validation f | **`POST /v1/sync`** | Authenticated profile mutations and cursor delta | | **`DELETE /v1/account-data`** | Deletes hosted data and rotates the user's epoch | +`POST /v1/sync` accepts the stable ecosystem v1 fields (`accountEpoch`, mutation `type`, and `SyncProfileV1`) as well as the desktop's paginated transport (`epoch`, mutation `kind`, receipts, records, and `hasMore`). A request must use one shape consistently. Responses contain both projections: `accountEpoch`, `acknowledgedMutationIds`, and `profiles` for the public contract, plus the paginated fields used by the desktop. The public projection preserves tombstone profile metadata so a delete delta includes `deletedAt`; account deletion still removes every profile and tombstone. + Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names contain 1–80 characters, source and target paths contain 1–4096 characters, and exclusions contain at most 100 patterns of 1–256 characters each. The shared contract, API DTO, desktop editor, and native persistence boundary enforce these same limits. The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. diff --git a/packages/cli/README.md b/packages/cli/README.md index 9b6d8e2..9949738 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,6 +9,6 @@ folder-sync ./source ./target --dry-run folder-sync ./source ./target --auto --json ``` -Use `--dry-run` to preview. `--json` never prompts; combine it with `--auto` for non-interactive application. Run `folder-sync --help` for the complete command reference. +Use `--dry-run` to preview. `--json` never prompts and is accepted only with `--dry-run` or `--auto`; use the latter for non-interactive application. Run `folder-sync --help` for the complete command reference. Documentation, source, security policy, and release status: diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9292ad2..e9ea80a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -58,7 +58,7 @@ function usage(): string { " -v, --verbose Include scan details in text output", " -a, --auto Create every missing folder without prompts", " --config Read exclusions from this JSON file", - " --json Emit one JSON document and never prompt", + " --json Emit one JSON document; requires --dry-run or --auto", " --version Print the package version", ].join("\n"); } @@ -94,6 +94,9 @@ function parseArguments(arguments_: readonly string[]): CliOptions { if (!help && !showVersion && positional.length !== 2) { throw new UsageError("Source and target arguments are required."); } + if (!help && !showVersion && json && !dryRun && !auto) { + throw new UsageError("--json requires --dry-run or --auto."); + } return { source: positional[0], target: positional[1], dryRun, verbose, auto, json, configPath, help, version: showVersion }; } @@ -130,7 +133,8 @@ export async function run( resolve(cwd, options.source!), resolve(cwd, options.target!), ); - validateRootRelationship(sourcePath, targetPath, config.targetCaseSensitive); + const targetCaseSensitive = config.targetCaseSensitive ?? await adapter.detectCaseSensitivity(targetPath); + validateRootRelationship(sourcePath, targetPath, targetCaseSensitive); const source = await adapter.scanDirectories(sourcePath, config.exclusions, "source"); let targetStatus = await adapter.ensureTarget(targetPath, options.dryRun || !options.auto); if (targetStatus === "would-create" && !options.dryRun && !options.json) { @@ -140,9 +144,15 @@ export async function run( targetStatus = await adapter.ensureTarget(targetPath); } const target = targetStatus === "would-create" - ? { snapshot: createSnapshot([]), skippedSymlinks: [] } + ? { + snapshot: createSnapshot([], { + rootPath: targetPath, + caseSensitivity: targetCaseSensitive ? "sensitive" : "insensitive", + }), + skippedSymlinks: [], + } : await adapter.scanDirectories(targetPath, config.exclusions, "target"); - const plan = createSyncPlan(source.snapshot, target.snapshot, config.targetCaseSensitive); + const plan = createSyncPlan(source.snapshot, target.snapshot, targetCaseSensitive); const output: CliOutput = { source: { entries: source.snapshot.entries.length, skippedSymlinks: source.skippedSymlinks }, target: { status: targetStatus, entries: target.snapshot.entries.length }, diff --git a/packages/cli/src/node-adapter.ts b/packages/cli/src/node-adapter.ts index a9b3e0f..274774c 100644 --- a/packages/cli/src/node-adapter.ts +++ b/packages/cli/src/node-adapter.ts @@ -1,5 +1,7 @@ import { promises as fs } from "node:fs"; +import { execFile as execFileCallback } from "node:child_process"; import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; import { ROOTLINE_ERROR_CODES, @@ -39,10 +41,12 @@ export const DEFAULT_EXCLUSIONS = [ ".nyc_output", ] as const; +const execFile = promisify(execFileCallback); + export interface ResolvedConfig { readonly path?: string; readonly exclusions: readonly string[]; - readonly targetCaseSensitive: boolean; + readonly targetCaseSensitive?: boolean; } export interface ResolveConfigOptions { @@ -83,7 +87,7 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise { + const ancestor = await this.nearestExistingAncestor(path); + if (process.platform === "darwin") { + try { + const { stdout } = await execFile("diskutil", ["info", ancestor], { encoding: "utf8" }); + const personality = stdout.split("\n").find((line) => line.includes("File System Personality:")); + return personality?.toLocaleLowerCase().includes("case-sensitive") ?? false; + } catch { + return false; + } + } + if (process.platform === "win32") { + try { + const { stdout } = await execFile("fsutil.exe", ["file", "queryCaseSensitiveInfo", ancestor], { encoding: "utf8" }); + return /enabled/i.test(stdout); + } catch { + return false; + } + } + return true; } async ensureTarget(targetPath: string, dryRun = false): Promise<"created" | "already-exists" | "would-create"> { @@ -265,6 +295,13 @@ export class NodeFileSystemAdapter { this.scanDirectories(canonicalSource, options.exclusions, "source", options.signal), this.scanDirectories(canonicalTarget, options.exclusions, "target", options.signal), ]); + if ((plan.sourceRoot && plan.sourceRoot !== currentSource.snapshot.rootPath) + || (plan.targetRoot && plan.targetRoot !== currentTarget.snapshot.rootPath)) { + throw createRootlineError({ + code: ROOTLINE_ERROR_CODES.STALE_PLAN, + message: "The selected roots differ from the reviewed plan.", + }); + } assertPlanFresh(plan, currentSource.snapshot, currentTarget.snapshot); const selected = options.selected ?? plan.missing; const directories = selectPlanSubtree(plan, selected); @@ -326,6 +363,21 @@ export class NodeFileSystemAdapter { } } + private async nearestExistingAncestor(path: string): Promise { + let current = resolve(path); + while (true) { + try { + await fs.lstat(current); + return current; + } catch (error: unknown) { + if (!isMissing(error)) throw unreadable(current, error); + } + const parent = dirname(current); + if (parent === current) throw unreadable(path); + current = parent; + } + } + private async assertNoLinkedAncestor(path: string): Promise { let current = resolve(path); while (true) { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index a331ea3..5d680c9 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -53,7 +53,7 @@ describe("folder-sync command", () => { expect(JSON.parse(result.stdout)).toMatchObject({ target: { status: "created" } }); }); - it("keeps a missing target untouched in JSON mode without --auto", async () => { + it("rejects JSON mode unless it is explicitly dry-run or auto", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); const target = join(workspace, "target"); @@ -61,10 +61,9 @@ describe("folder-sync command", () => { const result = run(source, target, "--json"); - expect(result.status).toBe(0); + expect(result.status).toBe(2); expect(JSON.parse(result.stdout)).toMatchObject({ - target: { status: "would-create" }, - plan: { missing: ["src"] }, + error: { code: "CONFIG_INVALID" }, }); await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); }); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index 7c6476f..8bfe79e 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -94,6 +94,20 @@ describe("Node filesystem adapter", () => { }); }); + it("detects the target filesystem case policy without mutating the scanned directory", async () => { + const root = await tempDirectory(); + const sentinel = join(root, "CasePolicySentinel"); + await writeFile(sentinel, "unchanged"); + const before = await readFile(sentinel, "utf8"); + const adapter = new NodeFileSystemAdapter(); + + const detected = await adapter.detectCaseSensitivity(root); + const actual = !(await lstat(join(root, "casepolicysentinel")).then(() => true, () => false)); + + expect(detected).toBe(actual); + expect(await readFile(sentinel, "utf8")).toBe(before); + }); + it("revalidates stale plans before mkdir and reports per-directory results", async () => { const target = await tempDirectory(); const sourceRoot = await tempDirectory(); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 34a89eb..6d220c8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -47,6 +47,28 @@ export const SYNC_MODES = ["additive"] as const; export type SyncMode = (typeof SYNC_MODES)[number]; +export type CaseSensitivity = "sensitive" | "insensitive"; + +export interface DirectorySnapshot { + rootPath: string; + caseSensitivity: CaseSensitivity; + directories: readonly string[]; + skippedLinks: readonly string[]; +} + +export interface PlanOperation { + id: string; + type: "create-directory"; + relativePath: string; +} + +export interface SyncPlan { + sourceRoot: string; + targetRoot: string; + operations: readonly PlanOperation[]; + fingerprint: string; +} + /** A complete local profile, including the absolute paths intentionally synced by cloud. */ export interface SyncProfile { id: string; @@ -120,6 +142,36 @@ export interface CloudSyncResponse { receipts: readonly CloudMutationReceipt[]; } +/** Stable v1 wire contract retained for ecosystem clients and documentation. */ +export interface SyncProfileV1 { + id: string; + schemaVersion: 1; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + revision: string; + deletedAt: string | null; +} + +export type SyncMutation = + | { mutationId: string; type: "upsert"; profile: SyncProfileV1 } + | { mutationId: string; type: "delete"; profileId: string }; + +export interface SyncRequest { + deviceId: string; + accountEpoch: string | null; + cursor: string; + mutations: SyncMutation[]; +} + +export interface SyncResponse { + accountEpoch: string; + cursor: string; + acknowledgedMutationIds: string[]; + profiles: SyncProfileV1[]; +} + export interface AccountDataDeletionRequest { epoch: string; } @@ -148,6 +200,13 @@ export const ROOTLINE_ERROR_CODES = { RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", + INVALID_ROOT: "INVALID_ROOT", + OVERLAPPING_ROOTS: "OVERLAPPING_ROOTS", + CREATE_FAILED: "CREATE_FAILED", + SYNC_OFFLINE: "SYNC_OFFLINE", + SYNC_REJECTED: "SYNC_REJECTED", + RESET_REQUIRED: "RESET_REQUIRED", + SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED", } as const; export type RootlineErrorCode = diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index b906743..f326d83 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -4,7 +4,7 @@ import * as contracts from "../src/index.js"; describe("Rootline contracts", () => { it("exposes stable shared error codes and structured errors", () => { - expect(contracts.ROOTLINE_ERROR_CODES).toEqual({ + expect(contracts.ROOTLINE_ERROR_CODES).toMatchObject({ CANCELLED: "CANCELLED", CONFIG_INVALID: "CONFIG_INVALID", INVALID_PATH: "INVALID_PATH", @@ -24,6 +24,13 @@ describe("Rootline contracts", () => { RATE_LIMITED: "RATE_LIMITED", VALIDATION_FAILED: "VALIDATION_FAILED", INTERNAL: "INTERNAL", + INVALID_ROOT: "INVALID_ROOT", + OVERLAPPING_ROOTS: "OVERLAPPING_ROOTS", + CREATE_FAILED: "CREATE_FAILED", + SYNC_OFFLINE: "SYNC_OFFLINE", + SYNC_REJECTED: "SYNC_REJECTED", + RESET_REQUIRED: "RESET_REQUIRED", + SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED", }); const error = contracts.createRootlineError({ diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts index b9144ce..2119086 100644 --- a/packages/contracts/test/contracts.types.test.ts +++ b/packages/contracts/test/contracts.types.test.ts @@ -2,12 +2,38 @@ import { expectTypeOf, test } from "vitest"; import type { CloudSyncRequest, + CaseSensitivity, + DirectorySnapshot, + PlanOperation, ProfileRecord, RootlineErrorCode, + SyncMutation, + SyncProfileV1, + SyncRequest, + SyncResponse, + SyncPlan, SyncProfile, } from "../src/index.js"; test("public domain and cloud contracts retain their transport shapes", () => { + expectTypeOf().toEqualTypeOf<"sensitive" | "insensitive">(); + expectTypeOf().toEqualTypeOf<{ + rootPath: string; + caseSensitivity: CaseSensitivity; + directories: readonly string[]; + skippedLinks: readonly string[]; + }>(); + expectTypeOf().toEqualTypeOf<{ + id: string; + type: "create-directory"; + relativePath: string; + }>(); + expectTypeOf().toEqualTypeOf<{ + sourceRoot: string; + targetRoot: string; + operations: readonly PlanOperation[]; + fingerprint: string; + }>(); expectTypeOf().toMatchTypeOf<{ id: string; name: string; @@ -29,6 +55,33 @@ test("public domain and cloud contracts retain their transport shapes", () => { | { kind: "tombstone"; profileId: string; deletedAt: string; revision: number } >(); + expectTypeOf().toEqualTypeOf<{ + id: string; + schemaVersion: 1; + name: string; + sourcePath: string; + targetPath: string; + exclusions: string[]; + revision: string; + deletedAt: string | null; + }>(); + expectTypeOf().toEqualTypeOf< + | { mutationId: string; type: "upsert"; profile: SyncProfileV1 } + | { mutationId: string; type: "delete"; profileId: string } + >(); + expectTypeOf().toEqualTypeOf<{ + deviceId: string; + accountEpoch: string | null; + cursor: string; + mutations: SyncMutation[]; + }>(); + expectTypeOf().toEqualTypeOf<{ + accountEpoch: string; + cursor: string; + acknowledgedMutationIds: string[]; + profiles: SyncProfileV1[]; + }>(); + expectTypeOf().toEqualTypeOf< | "CANCELLED" | "CONFIG_INVALID" @@ -49,5 +102,12 @@ test("public domain and cloud contracts retain their transport shapes", () => { | "RATE_LIMITED" | "VALIDATION_FAILED" | "INTERNAL" + | "INVALID_ROOT" + | "OVERLAPPING_ROOTS" + | "CREATE_FAILED" + | "SYNC_OFFLINE" + | "SYNC_REJECTED" + | "RESET_REQUIRED" + | "SCHEMA_UNSUPPORTED" >(); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b786ae..28367f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,12 +6,27 @@ import { export { ROOTLINE_ERROR_CODES, RootlineError, createRootlineError }; +export type CaseSensitivity = "sensitive" | "insensitive"; + export interface DirectorySnapshot { + readonly rootPath: string; + readonly caseSensitivity: CaseSensitivity; + readonly directories: readonly string[]; + readonly skippedLinks: readonly string[]; readonly entries: readonly string[]; readonly fingerprint: string; } +export interface PlanOperation { + readonly id: string; + readonly type: "create-directory"; + readonly relativePath: string; +} + export interface SyncPlan { + readonly sourceRoot: string; + readonly targetRoot: string; + readonly operations: readonly PlanOperation[]; readonly sourceFingerprint: string; readonly targetFingerprint: string; readonly targetCaseSensitive: boolean; @@ -49,8 +64,26 @@ export function normalizeRelativePath(value: string): string { } function globToRegExp(pattern: string): RegExp { - const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, "[^/]*"); - return new RegExp(`^${escaped}$`); + let expression = ""; + for (let index = 0; index < pattern.length; index += 1) { + const token = pattern[index]!; + if (token === "*" && pattern[index + 1] === "*") { + index += 1; + if (pattern[index + 1] === "/") { + index += 1; + expression += "(?:.*/)?"; + } else { + expression += ".*"; + } + } else if (token === "*") { + expression += "[^/]*"; + } else if (token === "?") { + expression += "[^/]"; + } else { + expression += token.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + } + return new RegExp(`^${expression}$`); } /** Matches complete path segments, so `.git` never also excludes `.github`. */ @@ -92,21 +125,44 @@ export function compareRelativePaths(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -export function createSnapshot(entries: readonly string[]): DirectorySnapshot { +export interface SnapshotMetadata { + readonly rootPath?: string; + readonly caseSensitivity?: CaseSensitivity; + readonly skippedLinks?: readonly string[]; +} + +export function createSnapshot(entries: readonly string[], metadata: SnapshotMetadata = {}): DirectorySnapshot { const normalized = [...new Set(entries.map(normalizeRelativePath).filter(Boolean))].sort(compareRelativePaths); - return Object.freeze({ entries: Object.freeze(normalized), fingerprint: fingerprint(normalized) }); + const directories = Object.freeze(normalized); + return Object.freeze({ + rootPath: metadata.rootPath ?? "", + caseSensitivity: metadata.caseSensitivity ?? "sensitive", + directories, + skippedLinks: Object.freeze([...(metadata.skippedLinks ?? [])]), + entries: directories, + fingerprint: fingerprint(normalized), + }); } export function createSyncPlan( source: DirectorySnapshot, target: DirectorySnapshot, - targetCaseSensitive = true, + targetCaseSensitive = target.caseSensitivity === "sensitive", ): SyncPlan { const comparable = (entry: string) => targetCaseSensitive ? entry : entry.toLowerCase(); const targetEntries = new Set(target.entries.map(comparable)); const missing = source.entries.filter((entry) => !targetEntries.has(comparable(entry))); - const planEntries = [source.fingerprint, target.fingerprint, targetCaseSensitive ? "case-sensitive" : "case-insensitive", ...missing]; + const operations = missing.map((relativePath) => Object.freeze({ + id: fingerprint([source.rootPath, target.rootPath, relativePath]), + type: "create-directory" as const, + relativePath, + })); + const rootBinding = source.rootPath || target.rootPath ? [source.rootPath, target.rootPath] : []; + const planEntries = [...rootBinding, source.fingerprint, target.fingerprint, targetCaseSensitive ? "case-sensitive" : "case-insensitive", ...missing]; return Object.freeze({ + sourceRoot: source.rootPath, + targetRoot: target.rootPath, + operations: Object.freeze(operations), sourceFingerprint: source.fingerprint, targetFingerprint: target.fingerprint, targetCaseSensitive, @@ -164,7 +220,9 @@ export function assertPlanFresh( source: DirectorySnapshot, target: DirectorySnapshot, ): void { - const expected = createSyncPlan(source, target, plan.targetCaseSensitive); + const expectedSource = plan.sourceRoot ? source : createSnapshot(source.entries); + const expectedTarget = plan.targetRoot ? target : createSnapshot(target.entries); + const expected = createSyncPlan(expectedSource, expectedTarget, plan.targetCaseSensitive); if ( plan.sourceFingerprint !== source.fingerprint || plan.targetFingerprint !== target.fingerprint || diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 7a26566..8713543 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -23,6 +23,11 @@ describe("Rootline synchronization core", () => { expect(matchesExclusion(".github/workflows", [".git"])).toBe(false); expect(matchesExclusion("build/cache", ["build"])).toBe(true); expect(matchesExclusion("notes/error.log", ["*.log"])).toBe(true); + expect(matchesExclusion("notes/app1.log", ["app?.log"])).toBe(true); + expect(matchesExclusion("notes/app10.log", ["app?.log"])).toBe(false); + expect(matchesExclusion("generated/deep/cache", ["generated/**/cache"])).toBe(true); + expect(matchesExclusion("generated/cache", ["generated/**/cache"])).toBe(true); + expect(matchesExclusion("generated/deep/nested/cache", ["generated/*/cache"])).toBe(false); }); it("creates deterministic snapshots and dependency-complete subtree selections", () => { @@ -41,6 +46,34 @@ describe("Rootline synchronization core", () => { expect(createSnapshot(["z", "ä", "a"]).entries).toEqual(["a", "z", "ä"]); }); + it("exports immutable public snapshot and operation-plan fields", () => { + const source = createSnapshot(["docs/api", "docs"], { + rootPath: "/source", + caseSensitivity: "sensitive", + skippedLinks: ["linked"], + }); + const target = createSnapshot(["docs"], { + rootPath: "/target", + caseSensitivity: "insensitive", + }); + const plan = createSyncPlan(source, target, false); + + expect(source).toMatchObject({ + rootPath: "/source", + caseSensitivity: "sensitive", + directories: ["docs", "docs/api"], + skippedLinks: ["linked"], + }); + expect(plan).toMatchObject({ sourceRoot: "/source", targetRoot: "/target" }); + expect(plan.operations).toEqual([ + expect.objectContaining({ type: "create-directory", relativePath: "docs/api" }), + ]); + expect(plan.operations[0]?.id).toBe(createSyncPlan(source, target, false).operations[0]?.id); + expect(createSyncPlan(source, createSnapshot(["docs"], { rootPath: "/other-target" }), false).fingerprint) + .not.toBe(plan.fingerprint); + expect(Object.isFrozen(plan.operations)).toBe(true); + }); + it("rejects equal or overlapping synchronization roots", () => { expect(() => validateRootRelationship("/work/source", "/work/source/nested", true)).toThrow( "overlap", From eedcfae5edf13219d76e297958386d07e14521e4 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 12:55:01 +0700 Subject: [PATCH 21/26] fix: close Rootline plan acceptance gaps --- .github/workflows/ci.yml | 8 ++ .../migration.sql | 5 + apps/api/prisma/schema.prisma | 1 + apps/api/src/sync.service.ts | 15 ++- apps/api/test/sync.e2e.spec.ts | 12 ++- apps/desktop/src-tauri/src/lib.rs | 59 +++++++++++- apps/desktop/src-tauri/tests/native.rs | 61 ++++++++++++- apps/desktop/src/App.tsx | 91 +++++++++++++------ apps/desktop/src/auth.ts | 2 +- apps/desktop/src/components/DiffTree.tsx | 4 + apps/desktop/src/i18n.ts | 4 + apps/desktop/src/native.ts | 7 ++ apps/desktop/src/styles.css | 2 +- apps/desktop/src/test/App.test.tsx | 33 +++++++ apps/desktop/src/test/auth.test.ts | 4 +- docs/hosted-profile-sync.md | 5 +- packages/cli/test/node-adapter.test.ts | 16 ++++ packages/contracts/src/index.ts | 1 - packages/contracts/test/contracts.test.ts | 2 +- .../contracts/test/contracts.types.test.ts | 1 - tests/release-workflows.test.mjs | 8 ++ 21 files changed, 290 insertions(+), 51 deletions(-) create mode 100644 apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cad74d9..3f2b18e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,4 +136,12 @@ jobs: with: workspaces: apps/desktop/src-tauri - run: pnpm install --frozen-lockfile + - name: Run Windows filesystem adapter tests + if: runner.os == 'Windows' && matrix.target == 'x86_64-pc-windows-msvc' + shell: pwsh + run: | + pnpm --filter folder-structure-sync test + if ($LASTEXITCODE -ne 0) { throw "Node filesystem adapter tests failed on Windows." } + cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml + if ($LASTEXITCODE -ne 0) { throw "Rust filesystem adapter tests failed on Windows." } - run: pnpm --filter @rootline/desktop tauri build --target ${{ matrix.target }} --no-bundle diff --git a/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql b/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql new file mode 100644 index 0000000..dbfbf0d --- /dev/null +++ b/apps/api/prisma/migrations/20260815120000_last_device_id/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "profile_record" +ADD COLUMN "last_device_id" TEXT NOT NULL DEFAULT 'legacy-unknown'; + +ALTER TABLE "profile_record" +ALTER COLUMN "last_device_id" DROP DEFAULT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5a9553c..336d5ab 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,6 +29,7 @@ model ProfileRecord { profile Json? deletedAt DateTime? @map("deleted_at") revision BigInt + lastDeviceId String @map("last_device_id") committedAt DateTime @default(now()) @map("committed_at") owner UserSyncState @relation(fields: [subject], references: [subject], onDelete: Cascade) diff --git a/apps/api/src/sync.service.ts b/apps/api/src/sync.service.ts index 615ad36..d114d7a 100644 --- a/apps/api/src/sync.service.ts +++ b/apps/api/src/sync.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; +import { ROOTLINE_ERROR_CODES } from "@rootline/contracts"; import { createHash, randomUUID } from "node:crypto"; import { PrismaService } from "./prisma.service.js"; @@ -41,7 +42,7 @@ function decodeCursor(cursor: string | undefined, epoch: string): bigint { try { const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { epoch?: unknown; revision?: unknown }; if (typeof value.epoch !== "string" || typeof value.revision !== "string" || !/^\d+$/.test(value.revision)) throw new Error(); - if (value.epoch !== epoch) throw new ConflictException({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch }); + if (value.epoch !== epoch) throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch }); return BigInt(value.revision); } catch (error) { if (error instanceof ConflictException) throw error; @@ -169,7 +170,9 @@ export class SyncService { await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: request.epoch ?? randomUUID() } }); await tx.$queryRaw`SELECT "subject" FROM "user_sync_state" WHERE "subject" = ${subject} FOR UPDATE`; const state = await tx.userSyncState.findUniqueOrThrow({ where: { subject } }); - if (request.epoch !== null && state.epoch !== request.epoch) throw new ConflictException({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch: state.epoch }); + if (request.epoch !== null && state.epoch !== request.epoch) { + throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch: state.epoch }); + } const requestedRevision = decodeCursor(request.cursor, state.epoch); if (requestedRevision > state.revision) throw new BadRequestException("Cursor revision is ahead of the server."); await tx.mutationReceipt.deleteMany({ where: { expiresAt: { lte: new Date() } } }); @@ -203,12 +206,12 @@ export class SyncService { create: { subject, profileId: mutation.kind === "upsert" ? mutation.profile!.id : mutation.profileId!, kind: mutation.kind === "upsert" ? "profile" : "tombstone", profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, - deletedAt: mutation.kind === "delete" ? committedAt : null, revision, committedAt, + deletedAt: mutation.kind === "delete" ? committedAt : null, revision, lastDeviceId: request.deviceId, committedAt, }, update: { kind: mutation.kind === "upsert" ? "profile" : "tombstone", profile: mutation.kind === "upsert" ? json(mutation.profile) : retainedProfile ? json(retainedProfile) : Prisma.JsonNull, - deletedAt: mutation.kind === "delete" ? committedAt : null, revision, committedAt, + deletedAt: mutation.kind === "delete" ? committedAt : null, revision, lastDeviceId: request.deviceId, committedAt, }, }); await tx.syncChange.create({ data: { subject, revision, record: json(record), committedAt } }); @@ -253,7 +256,9 @@ export class SyncService { await tx.userSyncState.upsert({ where: { subject }, update: {}, create: { subject, epoch: dto.epoch } }); await tx.$queryRaw`SELECT "subject" FROM "user_sync_state" WHERE "subject" = ${subject} FOR UPDATE`; const state = await tx.userSyncState.findUniqueOrThrow({ where: { subject } }); - if (state.epoch !== dto.epoch) throw new ConflictException({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch: state.epoch }); + if (state.epoch !== dto.epoch) { + throw new ConflictException({ code: ROOTLINE_ERROR_CODES.RESET_REQUIRED, epoch: state.epoch }); + } const epoch = randomUUID(); await tx.profileRecord.deleteMany({ where: { subject } }); await tx.syncChange.deleteMany({ where: { subject } }); diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index 8a9d7b5..bdb3626 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -157,6 +157,16 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { profiles: [{ ...profile, revision: "1" }], }); expect(created.body).toMatchObject({ epoch: created.body.accountEpoch, receipts: [{ mutationId }] }); + const deviceColumns = await postgres.$queryRaw>` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'profile_record' AND column_name = 'last_device_id' + `; + expect(deviceColumns).toEqual([{ column_name: "last_device_id" }]); + const storedDevice = await postgres.$queryRaw>` + SELECT last_device_id FROM profile_record + WHERE subject = 'public-contract-user' AND profile_id = 'public-profile' + `; + expect(storedDevice).toEqual([{ last_device_id: "public-device" }]); await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) .send({ deviceId: "public-device", @@ -304,7 +314,7 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { expect(deleted.body.epoch).not.toBe(EPOCH); const stale = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) .send({ deviceId: "stale-device", epoch: EPOCH, mutations: [mutation(PROFILE_ID, "Must not return", "00000000-0000-4000-8000-000000000402")] }).expect(409); - expect(stale.body).toEqual(expect.objectContaining({ code: "SYNC_EPOCH_RESET_REQUIRED", epoch: deleted.body.epoch })); + expect(stale.body).toEqual(expect.objectContaining({ code: "RESET_REQUIRED", epoch: deleted.body.epoch })); const current = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) .send({ deviceId: "fresh-device", epoch: deleted.body.epoch, mutations: [] }).expect(200); expect(current.body.records).toEqual([]); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 39c064b..1bec8cf 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -30,7 +30,7 @@ pub enum NativeErrorCode { StalePlan, AuthRequired, AuthCallbackInvalid, - SyncEpochResetRequired, + ResetRequired, SyncAccountClaimRequired, SyncStateChanged, ValidationFailed, @@ -207,6 +207,13 @@ pub struct DiffEntry { pub status: DiffStatus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRootAvailability { + pub source_available: bool, + pub target_available: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirectoryResult { @@ -315,6 +322,37 @@ fn assert_no_link_ancestors(path: &Path) -> Result<(), NativeError> { Ok(()) } +fn profile_root_available(path: &Path) -> Result { + let absolute = absolute(path)?; + if assert_no_link_ancestors(&absolute).is_err() { + return Ok(false); + } + match fs::symlink_metadata(&absolute) { + Ok(metadata) => Ok(metadata.is_dir() && !is_link_or_junction(&metadata)), + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.kind() == std::io::ErrorKind::PermissionDenied => + { + Ok(false) + } + Err(error) => Err(NativeError::at( + NativeErrorCode::UnreadablePath, + error.to_string(), + &absolute, + )), + } +} + +pub fn inspect_profile_roots( + source_path: &Path, + target_path: &Path, +) -> Result { + Ok(ProfileRootAvailability { + source_available: profile_root_available(source_path)?, + target_available: profile_root_available(target_path)?, + }) +} + #[cfg(target_os = "macos")] fn is_allowed_platform_root_alias(path: &Path) -> bool { let expected = if path == Path::new("/var") { @@ -2482,6 +2520,14 @@ fn choose_folder(_role: String) -> Option { .map(|path| path.to_string_lossy().into_owned()) } +#[tauri::command] +fn inspect_saved_profile_roots( + source_path: PathBuf, + target_path: PathBuf, +) -> Result { + inspect_profile_roots(&source_path, &target_path) +} + #[tauri::command] async fn scan_directories( request: ScanRequest, @@ -2674,12 +2720,14 @@ where )); } if status == reqwest::StatusCode::CONFLICT - && body.get("code").and_then(serde_json::Value::as_str) - == Some("SYNC_EPOCH_RESET_REQUIRED") + && body + .get("code") + .and_then(serde_json::Value::as_str) + .is_some_and(|code| code == "RESET_REQUIRED" || code == "SYNC_EPOCH_RESET_REQUIRED") { let preserves_consented_outbox = database.preserves_consented_outbox(subject)?; return Err(NativeError { - code: NativeErrorCode::SyncEpochResetRequired, + code: NativeErrorCode::ResetRequired, message: if preserves_consented_outbox { "This account already has hosted data. Review the explicitly consented local profiles before uploading." } else { @@ -2891,7 +2939,7 @@ fn accept_hosted_epoch( ) -> Result<(), NativeError> { if subject.is_empty() || Uuid::parse_str(&epoch).is_err() { return Err(NativeError::new( - NativeErrorCode::SyncEpochResetRequired, + NativeErrorCode::ResetRequired, "Hosted sync returned an invalid account reset.", )); } @@ -3017,6 +3065,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ choose_folder, + inspect_saved_profile_roots, scan_directories, apply_directories, cancel_operation, diff --git a/apps/desktop/src-tauri/tests/native.rs b/apps/desktop/src-tauri/tests/native.rs index 2c76ec6..44f3134 100644 --- a/apps/desktop/src-tauri/tests/native.rs +++ b/apps/desktop/src-tauri/tests/native.rs @@ -1,9 +1,9 @@ use std::fs; use rootline_desktop::{ - apply_plan, detect_case_sensitive, random_vault_password, resolve_existing_vault_password, - scan_plan, CancellationToken, Database, DiffStatus, DirectoryStatus, NativeErrorCode, Profile, - ScanRequest, + apply_plan, detect_case_sensitive, inspect_profile_roots, random_vault_password, + resolve_existing_vault_password, scan_plan, CancellationToken, Database, DiffStatus, + DirectoryStatus, NativeErrorCode, Profile, ScanRequest, }; use rusqlite::Connection; use tempfile::tempdir; @@ -17,6 +17,23 @@ fn request(source: &std::path::Path, target: &std::path::Path) -> ScanRequest { } } +#[test] +fn saved_profile_root_inspection_is_read_only_and_reports_each_missing_root() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("target"); + fs::create_dir(&source).unwrap(); + let sentinel = source.join("sentinel"); + fs::write(&sentinel, "unchanged").unwrap(); + + let availability = inspect_profile_roots(&source, &target).unwrap(); + + assert!(availability.source_available); + assert!(!availability.target_available); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "unchanged"); + assert!(!target.exists()); +} + fn create_v5_database_with_invalid_profile(path: &std::path::Path, profile: &Profile) { let connection = Connection::open(path).unwrap(); connection @@ -403,6 +420,44 @@ fn skips_symbolic_links_instead_of_following_them() { ); } +#[cfg(windows)] +#[test] +fn skips_windows_junctions_and_rejects_a_junction_root() { + use std::process::Command; + + let source = tempdir().unwrap(); + let target = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(outside.path().join("secret")).unwrap(); + let junction = source.path().join("junction"); + let status = Command::new("cmd.exe") + .arg("/C") + .arg("mklink") + .arg("/J") + .arg(&junction) + .arg(outside.path()) + .status() + .unwrap(); + assert!(status.success()); + + let plan = scan_plan( + &request(source.path(), target.path()), + &CancellationToken::default(), + ) + .unwrap(); + assert!(plan.missing.is_empty()); + assert_eq!(plan.skipped_links, ["junction"]); + assert_eq!( + scan_plan( + &request(&junction, target.path()), + &CancellationToken::default(), + ) + .unwrap_err() + .code, + NativeErrorCode::InvalidPath, + ); +} + #[test] fn migrates_and_persists_offline_state_with_bounded_history() { let directory = tempdir().unwrap(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7cac3fa..1379c1a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -16,7 +16,12 @@ import { } from "./native"; type Step = "choose" | "scanning" | "review" | "applying" | "result"; -type RebindRole = "source" | "target" | null; +interface RebindState { + source: boolean; + target: boolean; +} + +const noRebind: RebindState = { source: false, target: false }; interface AppProps { gateway?: NativeGateway; @@ -47,6 +52,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const text = copy[locale]; const [profiles, setProfiles] = useState(initialProfile ? [initialProfile] : []); const [activeProfile, setActiveProfile] = useState(initialProfile); + const activeProfileRef = useRef(initialProfile); const [profileName, setProfileName] = useState(initialProfile?.name ?? ""); const [sourcePath, setSourcePath] = useState(initialProfile?.sourcePath ?? ""); const [targetPath, setTargetPath] = useState(initialProfile?.targetPath ?? ""); @@ -55,7 +61,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const [selected, setSelected] = useState>(new Set()); const [result, setResult] = useState(); const [error, setError] = useState(); - const [rebind, setRebind] = useState(null); + const [rebind, setRebind] = useState(noRebind); const [activeOperation, setActiveOperation] = useState(); const [deleteCandidate, setDeleteCandidate] = useState(); const headingRef = useRef(null); @@ -65,28 +71,43 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const deleteCancelRef = useRef(null); const deleteReturnFocusRef = useRef(null); const returnToScanRef = useRef(false); + const profileInspectionRef = useRef(0); const profileNameId = useId(); const deleteDialogTitleId = useId(); + const inspectSavedProfile = (profile: Profile): void => { + const inspection = ++profileInspectionRef.current; + setRebind(noRebind); + void gateway.inspectProfileRoots({ sourcePath: profile.sourcePath, targetPath: profile.targetPath }).then((availability) => { + if (profileInspectionRef.current !== inspection) return; + setRebind({ source: !availability.sourceAvailable, target: !availability.targetAvailable }); + }).catch(() => { + if (profileInspectionRef.current === inspection) setRebind({ source: true, target: true }); + }); + }; + const reconcileProfiles = (loaded: Profile[]): void => { setProfiles(loaded); - setActiveProfile((current) => { - if (!current) return undefined; - const replacement = loaded.find((profile) => profile.id === current.id); - if (replacement) { - setProfileName(replacement.name); - setSourcePath(replacement.sourcePath); - setTargetPath(replacement.targetPath); - } else { - setProfileName(""); - setSourcePath(""); - setTargetPath(""); - setPlan(undefined); - setResult(undefined); - setStep("choose"); - } - return replacement; - }); + const current = activeProfileRef.current; + if (!current) return; + const replacement = loaded.find((profile) => profile.id === current.id); + activeProfileRef.current = replacement; + setActiveProfile(replacement); + if (replacement) { + setProfileName(replacement.name); + setSourcePath(replacement.sourcePath); + setTargetPath(replacement.targetPath); + inspectSavedProfile(replacement); + } else { + profileInspectionRef.current += 1; + setProfileName(""); + setSourcePath(""); + setTargetPath(""); + setPlan(undefined); + setResult(undefined); + setRebind(noRebind); + setStep("choose"); + } }; useEffect(() => { @@ -100,6 +121,10 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina return () => { live = false; }; }, [gateway, initialProfile]); + useEffect(() => { + if (initialProfile) inspectSavedProfile(initialProfile); + }, [gateway, initialProfile]); + useEffect(() => { if (!auth) return; let live = true; @@ -145,9 +170,10 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const choose = async (role: "source" | "target"): Promise => { const path = await gateway.chooseFolder({ role }); if (!path) return; + profileInspectionRef.current += 1; if (role === "source") setSourcePath(path); else setTargetPath(path); - setRebind(null); + setRebind((current) => ({ ...current, [role]: false })); setError(undefined); setStep("choose"); }; @@ -161,8 +187,9 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const scan = async (): Promise => { const id = operationId("scan"); + profileInspectionRef.current += 1; setError(undefined); - setRebind(null); + setRebind(noRebind); setActiveOperation(id); setStep("scanning"); try { @@ -176,11 +203,11 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina returnToScanRef.current = true; setStep("choose"); } else if (failure.code === "SOURCE_NOT_FOUND") { - setRebind("source"); + setRebind((current) => ({ ...current, source: true })); setError(text.sourceMissing); setStep("choose"); } else if (failure.code === "TARGET_NOT_FOUND") { - setRebind("target"); + setRebind((current) => ({ ...current, target: true })); setError(text.targetMissing); setStep("choose"); } else { @@ -227,6 +254,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina }; const selectProfile = (profile: Profile): void => { + activeProfileRef.current = profile; setActiveProfile(profile); setProfileName(profile.name); setSourcePath(profile.sourcePath); @@ -235,6 +263,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina setResult(undefined); setError(undefined); setStep("choose"); + inspectSavedProfile(profile); }; const requestProfileDelete = (profile: Profile, returnFocus: HTMLElement): void => { @@ -283,12 +312,15 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina }; const newProfile = (): void => { + profileInspectionRef.current += 1; + activeProfileRef.current = undefined; setActiveProfile(undefined); setProfileName(""); setSourcePath(""); setTargetPath(""); setPlan(undefined); setResult(undefined); + setRebind(noRebind); setStep("choose"); }; @@ -313,6 +345,7 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina const saved = await gateway.saveProfile(profile); setProfiles((current) => [saved, ...current.filter((entry) => entry.id !== saved.id)]); setActiveProfile(saved); + activeProfileRef.current = saved; setProfileName(saved.name); syncCoordinator?.profileEdited(); } catch (unknownError) { @@ -328,13 +361,15 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina await gateway.deleteProfile(deletedId); setProfiles((current) => current.filter((profile) => profile.id !== deletedId)); if (activeProfile?.id === deletedId) { + activeProfileRef.current = undefined; setActiveProfile(undefined); setProfileName(""); setSourcePath(""); setTargetPath(""); setPlan(undefined); setResult(undefined); - setRebind(null); + profileInspectionRef.current += 1; + setRebind(noRebind); setStep("choose"); } setDeleteCandidate(undefined); @@ -417,14 +452,14 @@ export function App({ gateway = tauriGateway, initialProfile, auth, syncCoordina

{text.chooseBody}

{error ?

{error}

: null}
-
+
01
{text.source}{sourcePath || text.notChosen}
- +
-
+
02
{text.target}{targetPath || text.notChosen}
- +
diff --git a/apps/desktop/src/auth.ts b/apps/desktop/src/auth.ts index a371149..13fe782 100644 --- a/apps/desktop/src/auth.ts +++ b/apps/desktop/src/auth.ts @@ -435,7 +435,7 @@ export class DesktopAuthController implements AuthController { this.update(next); } catch (error) { const value = error as { code?: unknown; message?: unknown; details?: { epoch?: unknown; preservesConsentedOutbox?: unknown } }; - if (value?.code === "SYNC_EPOCH_RESET_REQUIRED" && typeof value.details?.epoch === "string") { + if ((value?.code === "RESET_REQUIRED" || value?.code === "SYNC_EPOCH_RESET_REQUIRED") && typeof value.details?.epoch === "string") { this.resetEpoch = value.details.epoch; this.update({ ...this.current, diff --git a/apps/desktop/src/components/DiffTree.tsx b/apps/desktop/src/components/DiffTree.tsx index 081cae2..35f5954 100644 --- a/apps/desktop/src/components/DiffTree.tsx +++ b/apps/desktop/src/components/DiffTree.tsx @@ -9,6 +9,8 @@ export interface DiffTreeLabels { selected: string; clear: string; selectAllMissing: string; + expandAll: string; + collapseAll: string; folderDifferences: string; folderFilter: string; status: Record; @@ -175,6 +177,8 @@ export const DiffTree = memo(function DiffTree({ entries, selected, onSelectionC
+ +
diff --git a/apps/desktop/src/i18n.ts b/apps/desktop/src/i18n.ts index fa47a5e..a18b16d 100644 --- a/apps/desktop/src/i18n.ts +++ b/apps/desktop/src/i18n.ts @@ -148,6 +148,8 @@ export const copy = { selected: "Selected", clear: "Clear selection", selectAllMissing: "Select all missing", + expandAll: "Expand all", + collapseAll: "Collapse all", folderDifferences: "Folder differences", folderFilter: "Folder filter", status: { missing: "Missing", exists: "Exists", excluded: "Excluded", unreadable: "Unreadable" }, @@ -223,6 +225,8 @@ export const copy = { selected: "Đã chọn", clear: "Bỏ chọn", selectAllMissing: "Chọn tất cả thư mục còn thiếu", + expandAll: "Mở rộng tất cả", + collapseAll: "Thu gọn tất cả", folderDifferences: "Các thư mục khác biệt", folderFilter: "Bộ lọc thư mục", status: { missing: "Còn thiếu", exists: "Đã tồn tại", excluded: "Đã loại trừ", unreadable: "Không thể đọc" }, diff --git a/apps/desktop/src/native.ts b/apps/desktop/src/native.ts index c2f99ea..8daa015 100644 --- a/apps/desktop/src/native.ts +++ b/apps/desktop/src/native.ts @@ -47,8 +47,14 @@ export interface ApplyResult { directories: Array<{ relativePath: string; status: DirectoryStatus; error?: string }>; } +export interface ProfileRootAvailability { + sourceAvailable: boolean; + targetAvailable: boolean; +} + export interface NativeGateway { chooseFolder(input: { role: "source" | "target" }): Promise; + inspectProfileRoots(input: { sourcePath: string; targetPath: string }): Promise; scan(request: ScanRequest): Promise; apply(input: { request: ScanRequest; plan: ScanPlan; selected: string[]; profileId?: string }): Promise; cancel(operationId: string): Promise; @@ -59,6 +65,7 @@ export interface NativeGateway { export const tauriGateway: NativeGateway = { chooseFolder: ({ role }) => invoke("choose_folder", { role }), + inspectProfileRoots: (input) => invoke("inspect_saved_profile_roots", input), scan: (request) => invoke("scan_directories", { request }), apply: (command) => invoke("apply_directories", { command }), cancel: (operationId) => invoke("cancel_operation", { operationId }), diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ea3f8db..c1fe7fc 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -81,7 +81,7 @@ h1 { max-width: 760px; margin: 0; color: var(--graphite-950); font-size: clamp(3 @keyframes rootline-scan { from { transform: translateX(0); } to { transform: translateX(178px); } } .review-stage h1 { font-size: clamp(34px, 4.8vw, 58px); }.review-stage .lede { margin-bottom: 24px; }.safety-note { display: inline-block; margin: 0 0 16px; padding: 6px 9px; color: #6d5623; background: #d69b3b1c; font: 600 10px/1.3 ui-monospace, monospace; } -.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field:focus-within { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 2px; }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #20272a09; content-visibility: auto; cursor: pointer; }.tree-row:hover, .tree-row[aria-selected="true"] { background: #23bac70a; }.tree-row:focus-visible { z-index: 1; outline-offset: -3px; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; color: var(--cyan-600); font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.selection-box { width: 15px; height: 15px; display: grid; place-items: center; flex: 0 0 15px; border: 1px solid #7f8a85; border-radius: 3px; color: var(--graphite-950); background: var(--fog-50); font: 700 10px/1 sans-serif; }.tree-row[aria-selected="true"] .selection-box { border-color: var(--cyan-600); background: var(--cyan-500); }.status-spacer { width: 15px; flex: 0 0 15px; }.folder-glyph { width: 14px; height: 10px; flex: 0 0 14px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { min-width: 0; overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.diff-status { flex: 0 0 auto; margin-left: auto; margin-right: 14px; padding: 3px 6px; border-radius: 999px; color: #59635f; background: var(--fog-100); font: 700 9px/1.2 ui-monospace, monospace; }.diff-status.status-missing { color: #087d88; background: #23bac71a; }.diff-status.status-exists { color: #42604d; background: #587b641c; }.diff-status.status-excluded { color: #6d5623; background: #d69b3b1c; }.diff-status.status-unreadable { color: var(--danger); background: #c7584f1c; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; margin-bottom: 16px; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } +.diff-tree { border: 1px solid var(--fog-300); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, var(--fog-50) 96%, white); box-shadow: 0 16px 45px #20272a0a; }.tree-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--fog-100); }.search-field { display: flex; align-items: center; flex: 1; min-width: 160px; height: 34px; padding: 0 10px; border: 1px solid var(--fog-300); border-radius: 6px; background: var(--fog-50); }.search-field:focus-within { outline: 3px solid color-mix(in srgb, var(--cyan-500) 68%, white); outline-offset: 2px; }.search-field svg { width: 16px; height: 16px; margin-right: 7px; fill: none; stroke: #68716d; stroke-width: 1.5; }.search-field input { width: 100%; border: 0; outline: 0; color: var(--graphite-900); background: transparent; font-size: 12px; }.segmented { display: flex; padding: 2px; border: 1px solid var(--fog-300); border-radius: 6px; }.segmented button { padding: 6px 9px; border: 0; border-radius: 4px; color: #65706b; background: transparent; font-size: 10px; }.segmented button[aria-pressed="true"] { color: var(--graphite-950); background: var(--fog-100); }.quiet-button { min-height: 32px; padding: 6px 9px; font-size: 10px; }.tree-summary { display: flex; justify-content: space-between; padding: 8px 14px; color: #69736f; border-bottom: 1px solid var(--fog-100); font: 10px/1.3 ui-monospace, monospace; }.tree-viewport { height: 456px; overflow: auto; contain: strict; }.tree-spacer { position: relative; min-width: 100%; }.tree-row { position: absolute; left: 0; right: 0; height: 38px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #20272a09; content-visibility: auto; cursor: pointer; }.tree-row:hover, .tree-row[aria-selected="true"] { background: #23bac70a; }.tree-row:focus-visible { z-index: 1; outline-offset: -3px; }.disclosure { width: 23px; height: 23px; display: grid; place-items: center; flex: 0 0 23px; margin-right: 2px; color: var(--cyan-600); font: 18px/1 ui-monospace, monospace; }.branch { width: 25px; height: 12px; flex: 0 0 25px; border-left: 1px solid var(--fog-300); border-bottom: 1px solid var(--fog-300); }.selection-box { width: 15px; height: 15px; display: grid; place-items: center; flex: 0 0 15px; border: 1px solid #7f8a85; border-radius: 3px; color: var(--graphite-950); background: var(--fog-50); font: 700 10px/1 sans-serif; }.tree-row[aria-selected="true"] .selection-box { border-color: var(--cyan-600); background: var(--cyan-500); }.status-spacer { width: 15px; flex: 0 0 15px; }.folder-glyph { width: 14px; height: 10px; flex: 0 0 14px; border: 1px solid #7f8a85; border-radius: 2px; }.path-label { min-width: 0; overflow: hidden; color: var(--graphite-800); font: 11px/1.2 ui-monospace, "SFMono-Regular", monospace; text-overflow: ellipsis; white-space: nowrap; }.diff-status { flex: 0 0 auto; margin-left: auto; margin-right: 14px; padding: 3px 6px; border-radius: 999px; color: #59635f; background: var(--fog-100); font: 700 9px/1.2 ui-monospace, monospace; }.diff-status.status-missing { color: #087d88; background: #23bac71a; }.diff-status.status-exists { color: #42604d; background: #587b641c; }.diff-status.status-excluded { color: #6d5623; background: #d69b3b1c; }.diff-status.status-unreadable { color: var(--danger); background: #c7584f1c; }.review-actions { display: flex; justify-content: space-between; gap: 12px; margin-top: 18px; }.review-actions .primary-button { margin: 0; }.empty-state { display: grid; place-items: center; margin-bottom: 16px; padding: 58px 20px; border: 1px dashed var(--fog-300); text-align: center; background: #ffffff50; }.empty-state .brand-mark { width: 62px; height: 62px; margin-bottom: 14px; }.empty-state h2 { margin: 0; font-size: 19px; }.empty-state p { max-width: 470px; margin: 10px 0 0; color: #66706c; font-size: 13px; line-height: 1.55; } .result-stage { max-width: 760px; padding-top: 60px; }.result-mark { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 34px; border-radius: 50%; color: var(--fog-50); background: var(--moss-500); font-size: 26px; }.result-mark.cancelled { background: var(--amber-500); }.result-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 35px; border-block: 1px solid var(--fog-300); }.result-summary div { display: flex; align-items: baseline; gap: 9px; padding: 20px 14px; }.result-summary div + div { border-left: 1px solid var(--fog-300); }.result-summary strong { font: 500 30px/1 ui-monospace, monospace; }.result-summary span { color: #69736f; font-size: 11px; }.result-details { max-height: 260px; margin: 18px 0 0; padding: 0; overflow: auto; list-style: none; border: 1px solid var(--fog-300); border-radius: 8px; }.result-details li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 16px; padding: 10px 12px; }.result-details li + li { border-top: 1px solid var(--fog-100); }.result-path { overflow: hidden; font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }.result-status { color: #69736f; font-size: 11px; }.result-details small { grid-column: 1 / -1; color: var(--danger); }.failure-action { padding-left: 12px; color: var(--danger); border-left: 3px solid var(--danger); font-size: 12px; } diff --git a/apps/desktop/src/test/App.test.tsx b/apps/desktop/src/test/App.test.tsx index 3f4d03a..12d97ba 100644 --- a/apps/desktop/src/test/App.test.tsx +++ b/apps/desktop/src/test/App.test.tsx @@ -32,6 +32,7 @@ const plan: ScanPlan = { function gateway(overrides: Partial = {}): NativeGateway { return { chooseFolder: vi.fn(async ({ role }) => role === "source" ? "/projects/source" : "/projects/target"), + inspectProfileRoots: vi.fn(async () => ({ sourceAvailable: true, targetAvailable: true })), scan: vi.fn(async () => plan), apply: vi.fn(async () => ({ runId: "run-1", @@ -101,6 +102,27 @@ describe("Rootline desktop workflow", () => { expect(screen.getByRole("button", { name: "Rebind source" })).toBeInTheDocument(); }); + test("marks unavailable saved roots for rebind as soon as a profile is selected", async () => { + const user = userEvent.setup(); + const profile = { + id: "detached", name: "Detached", sourcePath: "/missing/source", targetPath: "/missing/target", + exclusions: [], createdAt: "x", updatedAt: "x", + }; + const inspectProfileRoots = vi.fn(async () => ({ sourceAvailable: false, targetAvailable: false })); + const native = gateway({ + listProfiles: vi.fn(async () => [profile]), + inspectProfileRoots, + } as Partial); + render(); + + await user.click(await screen.findByRole("option", { name: "Detached" })); + + expect(await screen.findByRole("button", { name: "Rebind source" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rebind target" })).toBeInTheDocument(); + expect(inspectProfileRoots).toHaveBeenCalledWith({ sourcePath: profile.sourcePath, targetPath: profile.targetPath }); + expect(native.scan).not.toHaveBeenCalled(); + }); + test("supports keyboard profile navigation, restores focus, Vietnamese copy, and has no serious axe violations", async () => { const user = userEvent.setup(); const native = gateway({ @@ -371,4 +393,15 @@ describe("DiffTree virtualization", () => { await user.keyboard("{End}"); expect(screen.getByRole("treeitem", { name: "src" })).toHaveFocus(); }); + + test("provides explicit localized controls to collapse and expand the whole tree", async () => { + const user = userEvent.setup(); + const entries = ["docs", "docs/api", "src"].map((relativePath) => ({ relativePath, status: "missing" as const })); + render(); + + await user.click(screen.getByRole("button", { name: "Collapse all" })); + expect(screen.queryByRole("treeitem", { name: "docs/api" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Expand all" })); + expect(screen.getByRole("treeitem", { name: "docs/api" })).toBeInTheDocument(); + }); }); diff --git a/apps/desktop/src/test/auth.test.ts b/apps/desktop/src/test/auth.test.ts index ffd10ba..7d4e70a 100644 --- a/apps/desktop/src/test/auth.test.ts +++ b/apps/desktop/src/test/auth.test.ts @@ -415,7 +415,7 @@ describe("Rootline desktop authentication boundary", () => { signinRedirectCallback: vi.fn(), revokeTokens: vi.fn(), removeUser: vi.fn(), }; tauriMocks.invoke.mockRejectedValue({ - code: "SYNC_EPOCH_RESET_REQUIRED", + code: "RESET_REQUIRED", message: "Existing hosted account epoch found.", details: { epoch: "00000000-0000-4000-8000-000000000123", @@ -424,7 +424,7 @@ describe("Rootline desktop authentication boundary", () => { }); const auth = new DesktopAuthController(config, manager as never); await auth.initialize(); - await expect(auth.sync()).rejects.toEqual(expect.objectContaining({ code: "SYNC_EPOCH_RESET_REQUIRED" })); + await expect(auth.sync()).rejects.toEqual(expect.objectContaining({ code: "RESET_REQUIRED" })); expect(auth.snapshot()).toEqual(expect.objectContaining({ epochResetRequired: true, epochResetPreservesConsentedOutbox: true, diff --git a/docs/hosted-profile-sync.md b/docs/hosted-profile-sync.md index 6d90a16..16bacb8 100644 --- a/docs/hosted-profile-sync.md +++ b/docs/hosted-profile-sync.md @@ -10,6 +10,7 @@ Production registration is an external deployment gate. Create an Authentik OAut | Setting | Required value | |---------|----------------| +| **Application slug** | `rootline` | | **Client type** | Public | | **Grant** | Authorization Code with PKCE | | **Redirect URI** | `rootline://auth/callback` | @@ -73,9 +74,9 @@ The stable deployment must provide TLS termination for the API, TLS validation f Both account endpoints require an RS256 token with the configured issuer, audience, subject, and `rootline:profiles:sync` permission. Tenant ownership always comes from the verified `sub`; request bodies cannot select another tenant. Requests are limited to 256 KiB, 100 mutations, and 60 authenticated requests per user per rolling minute. Profile names contain 1–80 characters, source and target paths contain 1–4096 characters, and exclusions contain at most 100 patterns of 1–256 characters each. The shared contract, API DTO, desktop editor, and native persistence boundary enforce these same limits. -The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. +The built-in rolling limiter is process-local. Run one API replica for this version. Horizontal scaling requires a shared, subject-keyed limiter before adding replicas; an ingress-only IP limit is not equivalent to the per-user contract. Every committed profile/tombstone also records the last device ID that submitted it, without logging profile paths. -Server commit arrival order is last-write-wins. Every new mutation advances a per-user revision and deletes become tombstones. Mutation receipt rows are physically retained for 90 days and purged opportunistically during a later sync, while a compact content-bound deduplication record remains for the lifetime of the account epoch. Replaying the same mutation ID and payload after receipt expiry therefore acknowledges its original revision without another write; reusing the ID with different content returns 409. Account deletion clears profiles, tombstones, changes, receipts, and deduplication records, then rotates the epoch. A stale device receives `SYNC_EPOCH_RESET_REQUIRED` and cannot silently resurrect deleted data. +Server commit arrival order is last-write-wins. Every new mutation advances a per-user revision and deletes become tombstones. Mutation receipt rows are physically retained for 90 days and purged opportunistically during a later sync, while a compact content-bound deduplication record remains for the lifetime of the account epoch. Replaying the same mutation ID and payload after receipt expiry therefore acknowledges its original revision without another write; reusing the ID with different content returns 409. Account deletion clears profiles, tombstones, changes, receipts, and deduplication records, then rotates the epoch. A stale device receives `RESET_REQUIRED` and cannot silently resurrect deleted data; clients still recognize the pre-release `SYNC_EPOCH_RESET_REQUIRED` spelling during upgrades. Mutation IDs are bound to a canonical content hash; reuse with different content returns 409 instead of silently dropping a change. Delta pages contain at most 100 records and approximately 1 MiB of record JSON. `hasMore` and the returned cursor let the desktop drain long-offline deltas while enforcing a 2 MiB streaming response cap. diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index 8bfe79e..888337f 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -52,6 +52,22 @@ describe("Node filesystem adapter", () => { expect(scan.skippedSymlinks).toEqual(["linked"]); }); + it.runIf(process.platform === "win32")("skips Windows junctions and rejects a junction root", async () => { + const root = await tempDirectory(); + const target = await tempDirectory(); + const outside = await tempDirectory(); + const junction = join(root, "junction"); + await mkdir(join(outside, "secret")); + await symlink(outside, junction, "junction"); + const adapter = new NodeFileSystemAdapter(); + + const scan = await adapter.scanDirectories(root, []); + + expect(scan.snapshot.entries).toEqual([]); + expect(scan.skippedSymlinks).toEqual(["junction"]); + await expect(adapter.validateRootPaths(junction, target)).rejects.toMatchObject({ code: "INVALID_PATH" }); + }); + it("honors legacy .ignore pruning and maps non-directory roots to unreadable errors", async () => { const root = await tempDirectory(); const file = join(root, "file"); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6d220c8..93eae6c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -194,7 +194,6 @@ export const ROOTLINE_ERROR_CODES = { AUTH_REQUIRED: "AUTH_REQUIRED", AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", PROFILE_CONFLICT: "PROFILE_CONFLICT", - SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", RATE_LIMITED: "RATE_LIMITED", diff --git a/packages/contracts/test/contracts.test.ts b/packages/contracts/test/contracts.test.ts index f326d83..5dd6bd7 100644 --- a/packages/contracts/test/contracts.test.ts +++ b/packages/contracts/test/contracts.test.ts @@ -18,7 +18,6 @@ describe("Rootline contracts", () => { AUTH_REQUIRED: "AUTH_REQUIRED", AUTH_CALLBACK_INVALID: "AUTH_CALLBACK_INVALID", PROFILE_CONFLICT: "PROFILE_CONFLICT", - SYNC_EPOCH_RESET_REQUIRED: "SYNC_EPOCH_RESET_REQUIRED", SYNC_ACCOUNT_CLAIM_REQUIRED: "SYNC_ACCOUNT_CLAIM_REQUIRED", SYNC_STATE_CHANGED: "SYNC_STATE_CHANGED", RATE_LIMITED: "RATE_LIMITED", @@ -32,6 +31,7 @@ describe("Rootline contracts", () => { RESET_REQUIRED: "RESET_REQUIRED", SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED", }); + expect(contracts.ROOTLINE_ERROR_CODES).not.toHaveProperty("SYNC_EPOCH_RESET_REQUIRED"); const error = contracts.createRootlineError({ code: contracts.ROOTLINE_ERROR_CODES.PATH_OVERLAP, diff --git a/packages/contracts/test/contracts.types.test.ts b/packages/contracts/test/contracts.types.test.ts index 2119086..ff73060 100644 --- a/packages/contracts/test/contracts.types.test.ts +++ b/packages/contracts/test/contracts.types.test.ts @@ -96,7 +96,6 @@ test("public domain and cloud contracts retain their transport shapes", () => { | "AUTH_REQUIRED" | "AUTH_CALLBACK_INVALID" | "PROFILE_CONFLICT" - | "SYNC_EPOCH_RESET_REQUIRED" | "SYNC_ACCOUNT_CLAIM_REQUIRED" | "SYNC_STATE_CHANGED" | "RATE_LIMITED" diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index 35212b6..92c5969 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -99,6 +99,12 @@ test("CI covers TypeScript quality, real PostgreSQL, Rust, npm smoke, and the su ]) assert.match(ci, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); const postgresSteps = parsedWorkflow("ci.yml").jobs["postgres-integration"].steps; assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("libwebkit2gtk-4.1-dev"))); + const tauriSteps = parsedWorkflow("ci.yml").jobs["tauri-build"].steps; + const windowsTests = tauriSteps.find((step) => step.name === "Run Windows filesystem adapter tests"); + assert.equal(windowsTests?.if, "runner.os == 'Windows' && matrix.target == 'x86_64-pc-windows-msvc'"); + assert.match(String(windowsTests?.run), /pnpm --filter folder-structure-sync test/); + assert.match(String(windowsTests?.run), /cargo test --manifest-path apps\/desktop\/src-tauri\/Cargo\.toml/); + assert.doesNotMatch(String(windowsTests?.run), /--target/); }); test("npm 2.0.0 release fails closed before provenance publishing", () => { @@ -243,10 +249,12 @@ test("desktop updater is registered and serves every default runtime target", () test("release and privacy docs describe the real ordering and lazy receipt cleanup", () => { const release = readFileSync(join(root, "docs", "release.md"), "utf8"); const privacy = readFileSync(join(root, "docs", "privacy.md"), "utf8"); + const hostedSync = readFileSync(join(root, "docs", "hosted-profile-sync.md"), "utf8"); const migrationIndex = release.indexOf("applies the checked-in migrations"); const deploymentIndex = release.indexOf("calls the HTTPS deployment webhook"); assert.ok(migrationIndex >= 0 && deploymentIndex >= 0 && migrationIndex < deploymentIndex); assert.match(privacy, /eligible for cleanup after 90 days/i); assert.match(privacy, /opportunistically on a later sync/i); + assert.match(hostedSync, /Application slug[^\n]*`rootline`/i); }); From 845e31b805273367cd780d246ab7f6676773da78 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 13:09:42 +0700 Subject: [PATCH 22/26] test: enforce Rootline plan acceptance --- apps/api/test/sync.e2e.spec.ts | 19 ++++ apps/desktop/src-tauri/tauri.conf.json | 3 - package.json | 1 + packages/cli/test/cli.test.ts | 29 +++++ tests/plan-acceptance.test.mjs | 149 +++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 tests/plan-acceptance.test.mjs diff --git a/apps/api/test/sync.e2e.spec.ts b/apps/api/test/sync.e2e.spec.ts index bdb3626..fd07c94 100644 --- a/apps/api/test/sync.e2e.spec.ts +++ b/apps/api/test/sync.e2e.spec.ts @@ -185,6 +185,25 @@ describe("Rootline hosted sync (real PostgreSQL)", () => { profile: { ...profile, syncMode: "additive" }, }], }).expect(400); + const privacyLog = vi.spyOn(Logger.prototype, "error"); + await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) + .send({ + deviceId: "public-device", + accountEpoch: created.body.accountEpoch, + cursor: created.body.cursor, + mutations: [{ + mutationId: "00000000-0000-4000-8000-000000000208", + type: "upsert", + profile: { + ...profile, + sourcePath: "/private/secret-source-that-must-not-be-logged", + directoryTree: ["private", "secret"], + runHistory: [{ result: "created" }], + }, + }], + }).expect(400); + expect(privacyLog).not.toHaveBeenCalled(); + privacyLog.mockRestore(); const deleteMutationId = "00000000-0000-4000-8000-000000000206"; const deleted = await request(fixture.server).post("/v1/sync").set("Authorization", `Bearer ${auth}`) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 49ee09c..c42676c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -37,9 +37,6 @@ "schemes": ["rootline"] } }, - "opener": { - "openUrl": true - }, "updater": { "endpoints": [ "https://github.com/unique01082/folder-structure-sync/releases/latest/download/latest.json" diff --git a/package.json b/package.json index 2bca96b..bc8d98a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", "test": "pnpm -r --if-present run test", "test:api-image": "sh apps/api/scripts/test-docker-image.sh", + "test:plan": "node --test tests/plan-acceptance.test.mjs", "test:unit": "pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", "test:release": "node --test tests/*.test.mjs", "validate:workflows": "node --test tests/release-workflows.test.mjs", diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 5d680c9..748f8b8 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -34,6 +34,16 @@ afterEach(async () => { }); describe("folder-sync command", () => { + it("prints help with every legacy and v2 flag", () => { + const result = run("--help"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + for (const flag of ["--dry-run", "--verbose", "--auto", "--config ", "--json"]) { + expect(result.stdout).toContain(flag); + } + }); + it("reads --version from the package metadata", () => { const result = run("--version"); @@ -41,6 +51,25 @@ describe("folder-sync command", () => { expect(result.stdout.trim()).toBe("2.0.0"); }); + it("previews a missing target in JSON dry-run mode without creating it or emitting terminal decoration", async () => { + const workspace = await tempDirectory(); + const source = join(workspace, "source"); + const target = join(workspace, "target"); + await mkdir(join(source, "docs", "api"), { recursive: true }); + + const result = run(source, target, "--dry-run", "--json"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).not.toContain(String.fromCharCode(27)); + expect(result.stdout).not.toMatch(/progress|spinner/i); + expect(JSON.parse(result.stdout)).toMatchObject({ + target: { status: "would-create" }, + plan: { missing: ["docs", "docs/api"] }, + }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("creates a missing target in auto JSON mode without prompting", async () => { const workspace = await tempDirectory(); const source = join(workspace, "source"); diff --git a/tests/plan-acceptance.test.mjs b/tests/plan-acceptance.test.mjs new file mode 100644 index 0000000..410fcb9 --- /dev/null +++ b/tests/plan-acceptance.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const read = (...parts) => readFileSync(join(root, ...parts), "utf8"); +const json = (...parts) => JSON.parse(read(...parts)); + +test("Task 1 preserves the exact npm 1.1.0 baseline and workspace contract", () => { + const tarballPath = join(root, "docs", "baseline", "folder-structure-sync-1.1.0.tgz"); + const tarball = readFileSync(tarballPath); + assert.equal(createHash("sha1").update(tarball).digest("hex"), "5cbc1470b492f36bad11653f2b8545a62daf5929"); + assert.equal( + createHash("sha512").update(tarball).digest("base64"), + "DMLwBKls8g/9ZSx2iSTW6ChOkuZSFjoe8cnn3k1NO7oM0D0sRNGXy2GuipFqhtukoUGwI1YdfENsHfzCZTjY1w==", + ); + const recoveredManifest = JSON.parse(execFileSync("tar", ["-xOzf", tarballPath, "package/package.json"], { encoding: "utf8" })); + const recoveredSource = execFileSync("tar", ["-xOzf", tarballPath, "package/index.js"], { encoding: "utf8" }); + assert.equal(recoveredManifest.version, "1.1.0"); + assert.match(recoveredSource, /\.ignore/); + + const evidence = read("docs", "baseline", "npm-1.1.0-recovery.md"); + assert.match(evidence, /a9cb35279f65db6e939c884a0503f2e13b3a5d93/); + assert.match(evidence, /not present in this clone/); + assert.match(evidence, /does not amend, reset, or otherwise rewrite repository history/); + + const manifests = [ + json("package.json"), json("apps", "api", "package.json"), json("apps", "desktop", "package.json"), + json("packages", "core", "package.json"), json("packages", "contracts", "package.json"), json("packages", "cli", "package.json"), + ]; + assert.deepEqual(manifests.map((manifest) => manifest.version), Array(6).fill("2.0.0")); + assert.deepEqual(manifests.map((manifest) => manifest.license), Array(6).fill("ISC")); + assert.deepEqual(manifests.filter((manifest) => manifest.private !== true).map((manifest) => manifest.name), ["folder-structure-sync"]); + assert.equal(manifests[0].private, true); + assert.match(manifests[0].engines.node, />=20/); + assert.match(read("pnpm-workspace.yaml"), /apps\/\*/); + assert.match(read("pnpm-workspace.yaml"), /packages\/\*/); +}); + +test("Tasks 2 and 3 keep the public CLI identity and desktop safety, identity, theme, and scope contracts", () => { + const cli = json("packages", "cli", "package.json"); + assert.equal(cli.name, "folder-structure-sync"); + assert.equal(cli.bin["folder-sync"], "./dist/index.js"); + assert.match(cli.engines.node, />=20/); + + const tauri = json("apps", "desktop", "src-tauri", "tauri.conf.json"); + assert.equal(tauri.productName, "Rootline by baole.space"); + assert.equal(tauri.identifier, "space.baole.rootline"); + assert.equal(tauri.version, "2.0.0"); + assert.deepEqual(tauri.plugins["deep-link"].desktop.schemes, ["rootline"]); + assert.equal(tauri.bundle.createUpdaterArtifacts, true); + assert.deepEqual(Object.keys(tauri.plugins.opener ?? {}).filter((key) => key !== "requireLiteralLeadingDot"), []); + assert.ok(json("apps", "desktop", "src-tauri", "capabilities", "default.json").permissions.includes("opener:default")); + + const css = read("apps", "desktop", "src", "styles.css"); + for (const token of ["--graphite-950", "--fog-50", "--cyan-500", "--moss-500", "--amber-500"]) assert.match(css, new RegExp(token)); + assert.match(css, /@media \(prefers-color-scheme: dark\)/); + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); + assert.match(css, /\.root-copy strong[^}]*ui-monospace/); + assert.deepEqual([...css.matchAll(/@keyframes\s+([\w-]+)/g)].map((match) => match[1]), ["rootline-scan"]); + assert.match(css, /\.scan-line[^}]*animation:\s*rootline-scan/); + + const readme = read("README.md"); + assert.match(readme, /one-way|source.+target/i); + assert.match(readme, /never directory trees, files, file contents, or run history/i); + assert.match(readme, /no usage telemetry/i); + const plan = read("docs", "superpowers", "plans", "2026-08-15-rootline-desktop-cli-v2.md"); + assert.match(plan, /No Linux, mobile, watcher, scheduler, mirror mode, or CLI cloud profiles/); + + const dependencyNames = Object.keys(json("apps", "desktop", "package.json").dependencies).join(" "); + assert.doesNotMatch(dependencyNames, /analytics|posthog|segment|sentry|telemetry/i); +}); + +test("Task 4 keeps Authentik, cloud privacy, profile-only sync, and reset contracts covered", () => { + const authTests = read("apps", "desktop", "src", "test", "auth.test.ts"); + for (const contract of ["openid profile email permissions offline_access", "rootline://auth/callback", "code_challenge_method", "nonce", "Storage.prototype"]) { + assert.match(authTests, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + const strongholdTests = read("apps", "desktop", "src", "test", "stronghold-storage.test.ts"); + assert.match(strongholdTests, /OS-keyed Stronghold store/); + assert.match(strongholdTests, /fails closed/); + + const dto = read("apps", "api", "src", "sync.dto.ts"); + assert.doesNotMatch(dto, /runHistory|directoryTree|fileContents/); + const apiTests = read("apps", "api", "test", "sync.e2e.spec.ts"); + for (const coverage of [ + "wrong issuer, audience, or permission", "tenant scoped", "idempotency", "LWW", "cursor deltas", "tombstones", + "offline", "90-day receipt", "account deletion", "shared profile limits", "body, and per-user request limits", "RESET_REQUIRED", + ]) assert.match(apiTests, new RegExp(coverage, "i")); + for (const forbiddenPayload of ["directoryTree", "runHistory", "secret-source-that-must-not-be-logged"]) { + assert.match(apiTests, new RegExp(forbiddenPayload)); + } + + const hostedDocs = read("docs", "hosted-profile-sync.md"); + assert.match(hostedDocs, /Application slug[^\n]*`rootline`/i); + assert.match(hostedDocs, /TLS validation/); + assert.match(hostedDocs, /encrypted backups/i); + assert.match(hostedDocs, /Directory trees, files, file contents, and run history never enter the hosted outbox/i); +}); + +test("Task 5 keeps every build, release, updater, documentation, and portal gate represented", () => { + const ci = read(".github", "workflows", "ci.yml"); + for (const gate of ["pnpm audit", "pnpm lint", "pnpm typecheck", "pnpm test:unit", "pnpm test:api-image", "cargo fmt", "cargo clippy", "cargo test", "test:pack"]) { + assert.match(ci, new RegExp(gate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + for (const target of ["universal-apple-darwin", "x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"]) assert.match(ci, new RegExp(target)); + + const release = read("docs", "release.md"); + for (const gate of ["notarization", "Authenticode", "updater", "blocked", "npm provenance"]) assert.match(release, new RegExp(gate, "i")); + assert.doesNotThrow(() => read("SECURITY.md")); + for (const document of ["privacy.md", "architecture.md", "configuration.md", "migration-v1-to-v2.md", "operations.md"]) { + assert.doesNotThrow(() => read("docs", document)); + } + + const releaseTests = read("tests", "release-workflows.test.mjs"); + assert.match(releaseTests, /desktop release requires platform signing and updater signing/i); + assert.match(releaseTests, /production image/i); + assert.match(releaseTests, /updater/i); + assert.match(read("tests", "updater-manifest.test.mjs"), /every supported runtime target/i); + + assert.match(release, /portal wording reflects actual availability/i); +}); + +test("the executable suites retain behavior-level coverage for every plan task", () => { + const suites = [ + read("packages", "core", "test", "core.test.ts"), + read("packages", "cli", "test", "node-adapter.test.ts"), + read("packages", "cli", "test", "cli.test.ts"), + read("apps", "desktop", "src", "test", "App.test.tsx"), + read("apps", "desktop", "src-tauri", "tests", "native.rs"), + read("apps", "desktop", "src", "test", "auth.test.ts"), + read("apps", "api", "test", "sync.e2e.spec.ts"), + ].join("\n"); + for (const behavior of [ + ".git", ".github", "traversal", "deterministic", "parent dependency", "overlapping", "case policy", "stale", "cancel", + "--help", "--version", "--dry-run", "--verbose", "--auto", "--config", "--json", "partial", "junction", + "50,000", "keyboard", "axe", "rebind", "Expand all", "Collapse all", "bounded[_ ]history", "outbox", "cursor", "vault", + "PKCE", "nonce", "offline", "tombstone", "rate", "RESET_REQUIRED", + ]) { + const expression = behavior === "bounded[_ ]history" + ? /bounded[_ ]history/i + : new RegExp(behavior.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + assert.match(suites, expression); + } +}); From 0b828eacedb284895a958079a2805f415a8fc103 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 13:27:37 +0700 Subject: [PATCH 23/26] feat: add signed desktop candidate workflow --- .../workflows/release-desktop-candidate.yml | 292 ++++++++++++++++++ docs/release.md | 8 + tests/release-workflows.test.mjs | 50 ++- 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-desktop-candidate.yml diff --git a/.github/workflows/release-desktop-candidate.yml b/.github/workflows/release-desktop-candidate.yml new file mode 100644 index 0000000..6d0c6b8 --- /dev/null +++ b/.github/workflows/release-desktop-candidate.yml @@ -0,0 +1,292 @@ +name: Build Rootline Desktop Candidate + +"on": + workflow_dispatch: + inputs: + channel: + description: Keep artifacts internal or publish a GitHub prerelease + required: true + default: internal + type: choice + options: + - internal + - public-beta + beta_tag: + description: Public beta tag in the form v2.0.0-beta.N + required: false + default: v2.0.0-beta.1 + type: string + confirmation: + description: Type build-rootline-candidate + required: true + type: string + +permissions: + contents: read + +concurrency: + group: desktop-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + preflight: + runs-on: ubuntu-24.04 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm audit --prod --audit-level high + - name: Install minisign for updater key-pair proof + run: sudo apt-get update && sudo apt-get install --yes minisign + - name: Require reviewed master, confirmation, signing credentials, and hosted endpoints + env: + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + CONFIRMATION: ${{ inputs.confirmation }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + run: | + set -eu + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "::error::Signed candidates must be built from reviewed master." + exit 1 + fi + if [ "${CONFIRMATION}" != "build-rootline-candidate" ]; then + echo "::error::Candidate build blocked. Use confirmation=build-rootline-candidate." + exit 1 + fi + if [ "${CHANNEL}" = "public-beta" ] && ! printf '%s' "${BETA_TAG}" | grep -Eq '^v2[.]0[.]0-beta[.][0-9]+$'; then + echo "::error::Public beta tag must match v2.0.0-beta.N." + exit 1 + fi + missing="" + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID APPLE_KEYCHAIN_PASSWORD WINDOWS_CERTIFICATE WINDOWS_CERTIFICATE_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD TAURI_UPDATER_PUBLIC_KEY VITE_AUTHENTIK_ISSUER VITE_AUTHENTIK_CLIENT_ID VITE_ROOTLINE_SYNC_API; do + eval "value=\${$name:-}" + [ -n "$value" ] || missing="$missing $name" + done + if [ -n "$missing" ]; then + echo "::error::Signed candidate blocked. Configure desktop-production secrets/variables:$missing" + exit 1 + fi + case "${VITE_AUTHENTIK_ISSUER}" in https://*) ;; *) echo "::error::VITE_AUTHENTIK_ISSUER must use HTTPS."; exit 1 ;; esac + case "${VITE_ROOTLINE_SYNC_API}" in https://*) ;; *) echo "::error::VITE_ROOTLINE_SYNC_API must use HTTPS."; exit 1 ;; esac + node -e "const p=require('./apps/desktop/package.json'); if(p.version !== '2.0.0') throw new Error('apps/desktop/package.json must be version 2.0.0')" + - name: Prove updater private key, password, and public key match + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + run: | + set -eu + challenge="$RUNNER_TEMP/rootline-candidate-key-pair-challenge" + signature="$challenge.sig" + public_key="$RUNNER_TEMP/rootline-updater-public.key" + minisign_signature="$RUNNER_TEMP/rootline-candidate.minisig" + printf '%s\n' "rootline-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" > "$challenge" + if ! pnpm --filter @rootline/desktop tauri signer sign "$challenge" >/dev/null; then + echo "::error::Candidate blocked: TAURI_SIGNING_PRIVATE_KEY or its password is invalid." + exit 1 + fi + if ! printf '%s' "$TAURI_UPDATER_PUBLIC_KEY" | base64 --decode > "$public_key"; then + echo "::error::Candidate blocked: TAURI_UPDATER_PUBLIC_KEY is not valid base64." + exit 1 + fi + if ! base64 --decode < "$signature" > "$minisign_signature"; then + echo "::error::Candidate blocked: Tauri produced an invalid updater signature." + exit 1 + fi + if ! minisign -Vm "$challenge" -p "$public_key" -x "$minisign_signature" >/dev/null; then + echo "::error::Candidate blocked: private key, password, and public key do not form one updater keypair." + exit 1 + fi + + macos-universal: + needs: preflight + runs-on: macos-15 + environment: desktop-production + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Developer ID certificate into an isolated keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/rootline.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security default-keychain -s "$RUNNER_TEMP/rootline.keychain-db" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security set-keychain-settings -t 3600 -u "$RUNNER_TEMP/rootline.keychain-db" + security import "$RUNNER_TEMP/rootline.p12" -k "$RUNNER_TEMP/rootline.keychain-db" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/rootline.keychain-db" + security find-identity -v -p codesigning "$RUNNER_TEMP/rootline.keychain-db" | grep -F "$APPLE_SIGNING_IDENTITY" + - name: Build, sign, notarize, and staple macOS Universal candidate + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + run: | + CANDIDATE_VERSION="2.0.0" + if [ "$CHANNEL" = "public-beta" ]; then CANDIDATE_VERSION="${BETA_TAG#v}"; fi + export CANDIDATE_VERSION + node -e 'require("node:fs").writeFileSync(process.env.RUNNER_TEMP + "/updater-config.json", JSON.stringify({version:process.env.CANDIDATE_VERSION,plugins:{updater:{pubkey:process.env.TAURI_UPDATER_PUBLIC_KEY}}}))' + pnpm --filter @rootline/desktop tauri build --target universal-apple-darwin --bundles app,dmg --config "$RUNNER_TEMP/updater-config.json" + - name: Verify and stage signed candidate + run: | + set -eu + bundle=apps/desktop/src-tauri/target/universal-apple-darwin/release/bundle + app=$(find "$bundle/macos" -maxdepth 1 -type d -name '*.app' -print -quit) + dmg=$(find "$bundle/dmg" -type f -name '*.dmg' -print -quit) + updater=$(find "$bundle" -type f -name '*.app.tar.gz' -print -quit) + [ -n "$app" ] && [ -n "$dmg" ] && [ -n "$updater" ] && [ -s "$updater.sig" ] + codesign --verify --deep --strict --verbose=2 "$app" + xcrun stapler validate "$dmg" + mkdir release-assets + cp "$dmg" release-assets/rootline-2.0.0-candidate-darwin-universal.dmg + cp "$updater" release-assets/rootline-2.0.0-candidate-darwin-universal.app.tar.gz + cp "$updater.sig" release-assets/rootline-2.0.0-candidate-darwin-universal.app.tar.gz.sig + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-candidate-darwin-universal + path: release-assets + if-no-files-found: error + + windows: + needs: preflight + environment: desktop-production + strategy: + fail-fast: false + matrix: + include: + - platform: windows-x86_64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + - platform: windows-aarch64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: pnpm + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: apps/desktop/src-tauri + - run: pnpm install --frozen-lockfile + - name: Import Windows Authenticode certificate + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certificatePath = Join-Path $env:RUNNER_TEMP "rootline.pfx" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password + if (-not $certificate.Thumbprint) { throw "Imported Windows certificate has no thumbprint." } + "WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + - name: Build and Authenticode-sign Windows candidate + shell: pwsh + env: + TAURI_TARGET: ${{ matrix.target }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_UPDATER_PUBLIC_KEY }} + VITE_AUTHENTIK_ISSUER: ${{ vars.VITE_AUTHENTIK_ISSUER }} + VITE_AUTHENTIK_CLIENT_ID: ${{ vars.VITE_AUTHENTIK_CLIENT_ID }} + VITE_ROOTLINE_SYNC_API: ${{ vars.VITE_ROOTLINE_SYNC_API }} + CHANNEL: ${{ inputs.channel }} + BETA_TAG: ${{ inputs.beta_tag }} + run: | + $version = "2.0.0" + if ($env:CHANNEL -eq "public-beta") { $version = $env:BETA_TAG.Substring(1) } + $config = @{ version = $version; bundle = @{ windows = @{ certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT; digestAlgorithm = "sha256"; timestampUrl = "http://timestamp.digicert.com" } }; plugins = @{ updater = @{ pubkey = $env:TAURI_UPDATER_PUBLIC_KEY } } } | ConvertTo-Json -Compress -Depth 5 + pnpm --filter @rootline/desktop tauri build --target $env:TAURI_TARGET --bundles nsis --config $config + if ($LASTEXITCODE -ne 0) { throw "Tauri Windows candidate build failed." } + - name: Verify and stage signed candidate + shell: pwsh + env: + PLATFORM: ${{ matrix.platform }} + TAURI_TARGET: ${{ matrix.target }} + run: | + $bundle = "apps/desktop/src-tauri/target/$env:TAURI_TARGET/release/bundle" + $installer = Get-ChildItem -Path $bundle -Recurse -File -Filter "*.exe" | Select-Object -First 1 + if (-not $installer -or -not (Test-Path "$($installer.FullName).sig")) { throw "Signed candidate artifacts are incomplete." } + $authenticode = Get-AuthenticodeSignature $installer.FullName + if ($authenticode.Status -ne "Valid") { throw "Installer Authenticode status is $($authenticode.Status)." } + New-Item -ItemType Directory -Path release-assets | Out-Null + Copy-Item $installer.FullName "release-assets/rootline-2.0.0-candidate-$env:PLATFORM-setup.exe" + Copy-Item "$($installer.FullName).sig" "release-assets/rootline-2.0.0-candidate-$env:PLATFORM-setup.exe.sig" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: rootline-candidate-${{ matrix.platform }} + path: release-assets + if-no-files-found: error + + publish-beta: + if: inputs.channel == 'public-beta' + needs: [preflight, macos-universal, windows] + runs-on: ubuntu-24.04 + environment: desktop-production + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: rootline-candidate-* + path: release-assets + merge-multiple: true + - name: Publish signed direct-download beta without touching the stable updater channel + env: + BETA_TAG: ${{ inputs.beta_tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + printf '%s\n' 'Rootline by baole.space signed beta. This prerelease does not publish latest.json; the installed beta can upgrade through the stable channel after v2.0.0 is released.' > release-notes.md + gh release create "$BETA_TAG" release-assets/* --prerelease --target "$GITHUB_SHA" --title "Rootline by baole.space ${BETA_TAG}" --notes-file release-notes.md diff --git a/docs/release.md b/docs/release.md index b5e45a6..9f56ce7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,6 +21,14 @@ pnpm validate:workflows pnpm test:release ``` +## Signed candidate and public beta + +Run `release-desktop-candidate.yml` manually from reviewed `master` with confirmation `build-rootline-candidate`. The protected `desktop-production` environment and the same Apple, Windows, updater, Authentik, and API values used by stable release are mandatory, so candidate testing exercises the real signing and hosted-profile configuration. + +The default `internal` channel uploads signed/notarized workflow artifacts only. After internal QA passes, rerun with channel `public-beta` and a new tag matching `v2.0.0-beta.N`; the workflow builds that SemVer prerelease, verifies every platform signature, and creates a GitHub prerelease. It does not publish `latest.json` or change the stable updater channel. A beta installation can upgrade after the signed `2.0.0` stable updater manifest is published. + +Use this sequence before stable release: internal signed candidate, public beta, then the API → npm → desktop stable train. Never promote an artifact from an unreviewed branch or reuse an existing beta tag. + ## npm `release-npm.yml` publishes only the public `folder-structure-sync` package. It runs only from the existing `v2.0.0` tag (with an additional exact confirmation for manual runs), checks the package version, and stops if the protected `npm-production` environment secret `NPM_TOKEN` is missing. An unprivileged job reruns quality/unit gates, installs the packed CLI in isolation, and uploads the exact tarball plus an independent checksum output. A minimal protected `npm-production` job verifies that checksum and packed identity, then exposes `NPM_TOKEN` only to `npm publish` with provenance and public access. `@rootline/core` and `@rootline/contracts` remain private workspace packages bundled into the CLI; they are never published independently. diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index 92c5969..91746e8 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -5,7 +5,13 @@ import assert from "node:assert/strict"; import { parse } from "yaml"; const root = process.cwd(); -const workflows = ["ci.yml", "release-npm.yml", "release-api.yml", "release-desktop.yml"]; +const workflows = [ + "ci.yml", + "release-npm.yml", + "release-api.yml", + "release-desktop.yml", + "release-desktop-candidate.yml", +]; function workflow(name) { return readFileSync(join(root, ".github", "workflows", name), "utf8"); @@ -227,6 +233,48 @@ test("desktop release requires platform signing and updater signing for every st assert.doesNotMatch(preflight, /continue-on-error/); }); +test("desktop candidate workflow signs internal artifacts and can publish only an isolated prerelease", () => { + const name = "release-desktop-candidate.yml"; + const candidate = workflow(name); + const parsed = parsedWorkflow(name); + + assert.deepEqual(Object.keys(parsed.on), ["workflow_dispatch"]); + assert.equal(parsed.jobs.preflight.environment, "desktop-production"); + assert.equal(parsed.jobs.preflight.steps.some((step) => String(step.run ?? "").includes("refs/heads/master")), true); + assert.match(candidate, /build-rootline-candidate/); + assert.ok(candidate.includes("grep -Eq '^v2[.]0[.]0-beta[.][0-9]+$'")); + assert.match(candidate, /pnpm audit --prod --audit-level high/); + for (const required of [ + "APPLE_CERTIFICATE", + "WINDOWS_CERTIFICATE", + "TAURI_SIGNING_PRIVATE_KEY", + "TAURI_UPDATER_PUBLIC_KEY", + "VITE_AUTHENTIK_ISSUER", + "VITE_ROOTLINE_SYNC_API", + "universal-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "tauri signer sign", + "minisign -Vm", + "--prerelease", + "--target", + ]) assert.match(candidate, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.deepEqual(parsed.jobs["publish-beta"].needs, ["preflight", "macos-universal", "windows"]); + assert.equal(parsed.jobs["publish-beta"].if, "inputs.channel == 'public-beta'"); + assert.equal(parsed.jobs["publish-beta"].permissions.contents, "write"); + assert.deepEqual(jobScopedSecrets(name), []); + assert.match(candidate, /CANDIDATE_VERSION="\$\{BETA_TAG#v\}"/); + assert.match(candidate, /Substring\(1\)/); + assert.doesNotMatch(candidate, /create-updater-manifest|release-assets\/latest\.json/); + assert.doesNotMatch(candidate, /refs\/tags\/v2\.0\.0(?:[^-]|$)/m); + assert.doesNotMatch(candidate, /npm publish|docker\/build-push-action/); + + const releaseDocs = readFileSync(join(root, "docs", "release.md"), "utf8"); + assert.match(releaseDocs, /release-desktop-candidate\.yml/); + assert.match(releaseDocs, /internal.+public beta.+stable/is); + assert.match(releaseDocs, /does not publish `latest\.json`/i); +}); + test("desktop updater is registered and serves every default runtime target", () => { const config = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "tauri.conf.json"), "utf8")); const cargo = readFileSync(join(root, "apps", "desktop", "src-tauri", "Cargo.toml"), "utf8"); From bcd83f23d4347b44cce2b98dbb886828414951f4 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 13:32:55 +0700 Subject: [PATCH 24/26] fix: bootstrap workspace builds on clean runners --- package.json | 8 +++++--- packages/cli/test/cli.test.ts | 3 ++- packages/cli/test/node-adapter.test.ts | 2 +- packages/cli/test/package-smoke.test.ts | 9 +++++---- tests/plan-acceptance.test.mjs | 3 +++ 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index bc8d98a..eb19204 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,17 @@ "packageManager": "pnpm@10.33.0", "scripts": { "audit:prod": "pnpm audit --prod --audit-level high", + "build:workspace-deps": "pnpm --filter @rootline/contracts build && pnpm --filter @rootline/core build", "build": "pnpm -r --if-present run build", "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", - "test": "pnpm -r --if-present run test", + "prepare": "pnpm build:workspace-deps", + "test": "pnpm build:workspace-deps && pnpm -r --if-present run test", "test:api-image": "sh apps/api/scripts/test-docker-image.sh", "test:plan": "node --test tests/plan-acceptance.test.mjs", - "test:unit": "pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", + "test:unit": "pnpm build:workspace-deps && pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", "test:release": "node --test tests/*.test.mjs", "validate:workflows": "node --test tests/release-workflows.test.mjs", - "typecheck": "pnpm -r --if-present run typecheck" + "typecheck": "pnpm build:workspace-deps && pnpm -r --if-present run typecheck" }, "pnpm": { "overrides": { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 748f8b8..bf446d1 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -8,6 +8,7 @@ import { run as runProgram } from "../src/index.js"; const directories: string[] = []; const cliPath = join(process.cwd(), "dist", "index.js"); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; async function tempDirectory(): Promise { const directory = await realpath(await mkdtemp(join(tmpdir(), "rootline-command-test-"))); @@ -20,7 +21,7 @@ function run(...arguments_: string[]) { } beforeAll(() => { - const build = spawnSync("pnpm", ["--filter", "folder-structure-sync", "build"], { + const build = spawnSync(pnpmCommand, ["--filter", "folder-structure-sync", "build"], { cwd: join(process.cwd(), "../.."), encoding: "utf8", }); diff --git a/packages/cli/test/node-adapter.test.ts b/packages/cli/test/node-adapter.test.ts index 888337f..a728cbd 100644 --- a/packages/cli/test/node-adapter.test.ts +++ b/packages/cli/test/node-adapter.test.ts @@ -83,7 +83,7 @@ describe("Node filesystem adapter", () => { }); }); - it("reports an unreadable child directory instead of silently creating an incomplete snapshot", async () => { + it.runIf(process.platform !== "win32")("reports an unreadable child directory instead of silently creating an incomplete snapshot", async () => { const root = await tempDirectory(); const locked = join(root, "locked"); await mkdir(locked); diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts index 7812324..1620516 100644 --- a/packages/cli/test/package-smoke.test.ts +++ b/packages/cli/test/package-smoke.test.ts @@ -7,6 +7,7 @@ import { afterAll, describe, expect, it } from "vitest"; const workspace = join(process.cwd(), "../.."); const scratch = await realpath(await mkdtemp(join(tmpdir(), "rootline-package-smoke-"))); +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; function execute(command: string, arguments_: string[], cwd = workspace) { const result = spawnSync(command, arguments_, { @@ -26,10 +27,10 @@ afterAll(async () => { describe("published CLI package", () => { it("installs from the public CLI tarball alone and runs the folder-sync binary", async () => { - execute("pnpm", ["--filter", "@rootline/contracts", "build"]); - execute("pnpm", ["--filter", "@rootline/core", "build"]); - execute("pnpm", ["--filter", "folder-structure-sync", "build"]); - execute("pnpm", ["--filter", "folder-structure-sync", "pack", "--pack-destination", scratch]); + execute(pnpmCommand, ["--filter", "@rootline/contracts", "build"]); + execute(pnpmCommand, ["--filter", "@rootline/core", "build"]); + execute(pnpmCommand, ["--filter", "folder-structure-sync", "build"]); + execute(pnpmCommand, ["--filter", "folder-structure-sync", "pack", "--pack-destination", scratch]); const install = join(scratch, "install"); const source = join(scratch, "source"); diff --git a/tests/plan-acceptance.test.mjs b/tests/plan-acceptance.test.mjs index 410fcb9..426047d 100644 --- a/tests/plan-acceptance.test.mjs +++ b/tests/plan-acceptance.test.mjs @@ -37,6 +37,9 @@ test("Task 1 preserves the exact npm 1.1.0 baseline and workspace contract", () assert.deepEqual(manifests.filter((manifest) => manifest.private !== true).map((manifest) => manifest.name), ["folder-structure-sync"]); assert.equal(manifests[0].private, true); assert.match(manifests[0].engines.node, />=20/); + assert.match(manifests[0].scripts.prepare, /build:workspace-deps/); + assert.match(manifests[0].scripts.typecheck, /build:workspace-deps/); + assert.match(manifests[0].scripts.test, /build:workspace-deps/); assert.match(read("pnpm-workspace.yaml"), /apps\/\*/); assert.match(read("pnpm-workspace.yaml"), /packages\/\*/); }); From 2c8791c9c55d66fbf3e0ebf911371124123465d3 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 13:49:42 +0700 Subject: [PATCH 25/26] fix: make clean CI runners self-contained --- .github/workflows/ci.yml | 1 + apps/desktop/src-tauri/tauri.conf.json | 2 +- package.json | 3 +-- packages/cli/test/cli.test.ts | 1 + packages/cli/test/package-smoke.test.ts | 1 + tests/plan-acceptance.test.mjs | 2 +- tests/release-workflows.test.mjs | 3 +++ 7 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f2b18e..3796bd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,7 @@ jobs: with: workspaces: apps/desktop/src-tauri - run: pnpm install --frozen-lockfile + - run: pnpm build:workspace-deps - run: pnpm --filter @rootline/api prisma:generate - run: pnpm --filter @rootline/api prisma:migrate:deploy - run: pnpm --filter @rootline/api test:e2e diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c42676c..f67e7ec 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "pnpm dev", "devUrl": "http://127.0.0.1:1420", - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "pnpm --dir ../.. build:workspace-deps && pnpm build", "frontendDist": "../dist" }, "app": { diff --git a/package.json b/package.json index eb19204..e8682b7 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,13 @@ "build:workspace-deps": "pnpm --filter @rootline/contracts build && pnpm --filter @rootline/core build", "build": "pnpm -r --if-present run build", "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\" \"tests/**/*.mjs\" --max-warnings 0", - "prepare": "pnpm build:workspace-deps", "test": "pnpm build:workspace-deps && pnpm -r --if-present run test", "test:api-image": "sh apps/api/scripts/test-docker-image.sh", "test:plan": "node --test tests/plan-acceptance.test.mjs", "test:unit": "pnpm build:workspace-deps && pnpm --filter @rootline/contracts test && pnpm --filter @rootline/core test && pnpm --filter folder-structure-sync test:unit && pnpm --filter @rootline/desktop test", "test:release": "node --test tests/*.test.mjs", "validate:workflows": "node --test tests/release-workflows.test.mjs", - "typecheck": "pnpm build:workspace-deps && pnpm -r --if-present run typecheck" + "typecheck": "pnpm build:workspace-deps && pnpm --filter @rootline/api prisma:generate && pnpm -r --if-present run typecheck" }, "pnpm": { "overrides": { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index bf446d1..3aff1a6 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -24,6 +24,7 @@ beforeAll(() => { const build = spawnSync(pnpmCommand, ["--filter", "folder-structure-sync", "build"], { cwd: join(process.cwd(), "../.."), encoding: "utf8", + shell: process.platform === "win32", }); if (build.status !== 0) { throw new Error(build.stderr || build.stdout); diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts index 1620516..bdb6f3f 100644 --- a/packages/cli/test/package-smoke.test.ts +++ b/packages/cli/test/package-smoke.test.ts @@ -14,6 +14,7 @@ function execute(command: string, arguments_: string[], cwd = workspace) { cwd, encoding: "utf8", env: { ...process.env, npm_config_cache: join(scratch, "npm-cache") }, + shell: process.platform === "win32", }); if (result.status !== 0) { throw new Error(`${command} ${arguments_.join(" ")} failed:\n${result.stderr || result.stdout}`); diff --git a/tests/plan-acceptance.test.mjs b/tests/plan-acceptance.test.mjs index 426047d..431cc6d 100644 --- a/tests/plan-acceptance.test.mjs +++ b/tests/plan-acceptance.test.mjs @@ -37,8 +37,8 @@ test("Task 1 preserves the exact npm 1.1.0 baseline and workspace contract", () assert.deepEqual(manifests.filter((manifest) => manifest.private !== true).map((manifest) => manifest.name), ["folder-structure-sync"]); assert.equal(manifests[0].private, true); assert.match(manifests[0].engines.node, />=20/); - assert.match(manifests[0].scripts.prepare, /build:workspace-deps/); assert.match(manifests[0].scripts.typecheck, /build:workspace-deps/); + assert.match(manifests[0].scripts.typecheck, /prisma:generate/); assert.match(manifests[0].scripts.test, /build:workspace-deps/); assert.match(read("pnpm-workspace.yaml"), /apps\/\*/); assert.match(read("pnpm-workspace.yaml"), /packages\/\*/); diff --git a/tests/release-workflows.test.mjs b/tests/release-workflows.test.mjs index 91746e8..a039cdd 100644 --- a/tests/release-workflows.test.mjs +++ b/tests/release-workflows.test.mjs @@ -105,6 +105,9 @@ test("CI covers TypeScript quality, real PostgreSQL, Rust, npm smoke, and the su ]) assert.match(ci, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); const postgresSteps = parsedWorkflow("ci.yml").jobs["postgres-integration"].steps; assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("libwebkit2gtk-4.1-dev"))); + assert.ok(postgresSteps.some((step) => String(step.run ?? "").includes("build:workspace-deps"))); + const tauriConfig = JSON.parse(readFileSync(join(root, "apps", "desktop", "src-tauri", "tauri.conf.json"), "utf8")); + assert.match(tauriConfig.build.beforeBuildCommand, /build:workspace-deps/); const tauriSteps = parsedWorkflow("ci.yml").jobs["tauri-build"].steps; const windowsTests = tauriSteps.find((step) => step.name === "Run Windows filesystem adapter tests"); assert.equal(windowsTests?.if, "runner.os == 'Windows' && matrix.target == 'x86_64-pc-windows-msvc'"); From e913f604cd4fc8f97aba5d4a0dbf12155b4d0519 Mon Sep 17 00:00:00 2001 From: Bao Le Date: Sat, 15 Aug 2026 13:52:11 +0700 Subject: [PATCH 26/26] fix: make CLI smoke tests portable on Windows --- packages/cli/test/cli.test.ts | 8 ++++++++ packages/cli/test/package-smoke.test.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 3aff1a6..76d887d 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -21,6 +21,14 @@ function run(...arguments_: string[]) { } beforeAll(() => { + const dependencies = spawnSync(pnpmCommand, ["build:workspace-deps"], { + cwd: join(process.cwd(), "../.."), + encoding: "utf8", + shell: process.platform === "win32", + }); + if (dependencies.status !== 0) { + throw new Error(dependencies.stderr || dependencies.stdout); + } const build = spawnSync(pnpmCommand, ["--filter", "folder-structure-sync", "build"], { cwd: join(process.cwd(), "../.."), encoding: "utf8", diff --git a/packages/cli/test/package-smoke.test.ts b/packages/cli/test/package-smoke.test.ts index bdb6f3f..e99ace5 100644 --- a/packages/cli/test/package-smoke.test.ts +++ b/packages/cli/test/package-smoke.test.ts @@ -38,7 +38,7 @@ describe("published CLI package", () => { const target = join(scratch, "target"); await Promise.all([mkdir(install), mkdir(join(source, "nested"), { recursive: true })]); const tarball = join(scratch, "folder-structure-sync-2.0.0.tgz"); - const listing = execute("tar", ["-tzf", tarball]).stdout.split("\n"); + const listing = execute("tar", ["-tzf", tarball]).stdout.split(/\r?\n/); expect(listing).toEqual(expect.arrayContaining(["package/LICENSE", "package/README.md"])); execute("tar", ["-xzf", tarball, "-C", scratch, "package/package.json"]); const packedManifest = JSON.parse(await readFile(join(scratch, "package", "package.json"), "utf8"));