From b447a650a7a43ec556e78871e8256f3c5cf0f9d7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Jul 2026 00:22:17 +0200 Subject: [PATCH] feat(desktop): migrate existing projects and worktrees --- .github/workflows/ci.yml | 67 ++ docs/architecture.md | 14 +- docs/testing.md | 9 + package-lock.json | 54 +- package.json | 11 +- .../e2e/conductor-migration.electron.spec.ts | 172 ++++ packages/app/e2e/global-setup.ts | 4 +- packages/app/e2e/helpers/seed-client.ts | 1 - packages/app/e2e/projects-settings.spec.ts | 180 +--- packages/app/package.json | 1 + .../app/settings/projects/[projectKey].tsx | 43 +- .../worktree-setup-callout-policy.test.ts | 46 -- .../worktree-setup-callout-policy.ts | 46 +- .../worktree-setup-callout-source.tsx | 53 +- packages/app/src/data/query.ts | 9 +- .../components/integrations-section.tsx | 2 + packages/app/src/desktop/host.ts | 25 + .../app/src/desktop/migrations/conductor.svg | 5 + .../app/src/desktop/migrations/conductor.tsx | 64 ++ .../desktop/migrations/migration-sheet.tsx | 185 +++++ packages/app/src/i18n/resources/ar.ts | 52 +- packages/app/src/i18n/resources/en.ts | 52 +- packages/app/src/i18n/resources/es.ts | 54 +- packages/app/src/i18n/resources/fr.ts | 54 +- packages/app/src/i18n/resources/ja.ts | 53 +- packages/app/src/i18n/resources/pt-BR.ts | 54 +- packages/app/src/i18n/resources/ru.ts | 52 +- packages/app/src/i18n/resources/zh-CN.ts | 52 +- .../src/project-config-import/availability.ts | 41 - .../project-config-import/preview-cache.ts | 82 -- .../project-config-import-model.test.ts | 396 --------- .../project-config-import-section.tsx | 115 --- .../project-config-import-sheet.tsx | 300 ------- .../app/src/project-config-import/retry.ts | 15 - .../app/src/project-config-import/route.ts | 106 --- .../sources/conductor.ts | 8 - .../sources/conductor.view-registration.ts | 12 - .../sources/conductor.view.tsx | 39 - .../sources/index.test.ts | 94 --- .../project-config-import/sources/index.ts | 106 --- .../sources/view-registry.ts | 37 - .../src/project-config-import/sources/view.ts | 17 - .../use-project-config-import-model.ts | 403 --------- .../src/screens/project-settings-screen.tsx | 96 +-- packages/app/src/screens/settings-screen.tsx | 16 +- packages/app/src/utils/host-routes.test.ts | 14 - packages/app/src/utils/host-routes.ts | 15 - .../app/src/utils/project-config-form.test.ts | 28 +- packages/app/src/utils/project-config-form.ts | 9 - packages/cli/src/utils/client.ts | 433 +--------- packages/client/package.json | 6 + packages/client/src/daemon-client.test.ts | 99 --- packages/client/src/daemon-client.ts | 51 -- packages/client/src/node.test.ts | 89 ++ packages/client/src/node.ts | 371 +++++++++ packages/desktop/package.json | 5 +- packages/desktop/src/daemon/daemon-manager.ts | 2 +- .../src/daemon/desktop-packaging.test.ts | 2 +- .../migrations/entrypoint.test.ts | 44 + .../src/integrations/migrations/entrypoint.ts | 52 ++ .../src/integrations/migrations/ipc.ts | 73 ++ .../integrations/migrations/process.test.ts | 196 +++++ .../src/integrations/migrations/process.ts | 206 +++++ packages/desktop/src/main.ts | 2 + packages/desktop/src/preload.ts | 31 + .../migrate/fixtures/conductor/conductor.db | Bin 0 -> 20480 bytes .../current/.conductor/settings.local.toml | 5 + .../current/.conductor/settings.toml | 13 + .../fixtures/conductor/legacy/conductor.json | 11 + packages/migrate/package.json | 51 ++ packages/migrate/src/cli.test.ts | 67 ++ packages/migrate/src/cli.ts | 117 +++ packages/migrate/src/migrate.test.ts | 211 +++++ packages/migrate/src/migrate.ts | 197 +++++ packages/migrate/src/output.ts | 11 + packages/migrate/src/paseo.ts | 9 + .../src/sources/conductor/database.test.ts | 107 +++ .../migrate/src/sources/conductor/database.ts | 214 +++++ .../migrate/src/sources/conductor/index.ts | 43 + .../src/sources/conductor/inspect.test.ts | 106 +++ .../migrate/src/sources/conductor/inspect.ts | 230 ++++++ .../sources/conductor/project-config.test.ts | 144 ++++ .../src/sources/conductor/project-config.ts | 651 +++++++++++++++ .../src/sources/conductor/sqlite.fixture.ts | 21 + packages/migrate/src/sources/index.ts | 12 + packages/migrate/src/types.ts | 70 ++ packages/migrate/tsconfig.json | 19 + packages/protocol/src/messages.test.ts | 17 - packages/protocol/src/messages.ts | 83 +- packages/protocol/src/paseo-config-schema.ts | 65 -- packages/server/package.json | 5 +- .../migration-host-automation.e2e.test.ts | 194 +++++ .../daemon-e2e/relay-transport.e2e.test.ts | 40 +- packages/server/src/server/session.ts | 4 - .../session/project-config/import/merge.ts | 123 --- .../project-config/import/registry.test.ts | 80 -- .../session/project-config/import/registry.ts | 77 -- .../project-config/import/service.test.ts | 156 ---- .../session/project-config/import/service.ts | 173 ---- .../import/sources/conductor/importer.test.ts | 772 ------------------ .../import/sources/conductor/importer.ts | 646 --------------- .../project-config-session.test.ts | 181 +--- .../project-config/project-config-session.ts | 123 --- .../src/server/test-utils/paseo-daemon.ts | 12 +- .../server/src/server/websocket-server.ts | 5 +- .../server/src/utils/worktree.posix.test.ts | 22 + packages/server/src/utils/worktree.ts | 11 + scripts/verify-migrate-package-contents.mjs | 38 + 108 files changed, 4576 insertions(+), 5585 deletions(-) create mode 100644 packages/app/e2e/conductor-migration.electron.spec.ts create mode 100644 packages/app/src/desktop/migrations/conductor.svg create mode 100644 packages/app/src/desktop/migrations/conductor.tsx create mode 100644 packages/app/src/desktop/migrations/migration-sheet.tsx delete mode 100644 packages/app/src/project-config-import/availability.ts delete mode 100644 packages/app/src/project-config-import/preview-cache.ts delete mode 100644 packages/app/src/project-config-import/project-config-import-model.test.ts delete mode 100644 packages/app/src/project-config-import/project-config-import-section.tsx delete mode 100644 packages/app/src/project-config-import/project-config-import-sheet.tsx delete mode 100644 packages/app/src/project-config-import/retry.ts delete mode 100644 packages/app/src/project-config-import/route.ts delete mode 100644 packages/app/src/project-config-import/sources/conductor.ts delete mode 100644 packages/app/src/project-config-import/sources/conductor.view-registration.ts delete mode 100644 packages/app/src/project-config-import/sources/conductor.view.tsx delete mode 100644 packages/app/src/project-config-import/sources/index.test.ts delete mode 100644 packages/app/src/project-config-import/sources/index.ts delete mode 100644 packages/app/src/project-config-import/sources/view-registry.ts delete mode 100644 packages/app/src/project-config-import/sources/view.ts delete mode 100644 packages/app/src/project-config-import/use-project-config-import-model.ts create mode 100644 packages/client/src/node.test.ts create mode 100644 packages/client/src/node.ts create mode 100644 packages/desktop/src/integrations/migrations/entrypoint.test.ts create mode 100644 packages/desktop/src/integrations/migrations/entrypoint.ts create mode 100644 packages/desktop/src/integrations/migrations/ipc.ts create mode 100644 packages/desktop/src/integrations/migrations/process.test.ts create mode 100644 packages/desktop/src/integrations/migrations/process.ts create mode 100644 packages/migrate/fixtures/conductor/conductor.db create mode 100644 packages/migrate/fixtures/conductor/current/.conductor/settings.local.toml create mode 100644 packages/migrate/fixtures/conductor/current/.conductor/settings.toml create mode 100644 packages/migrate/fixtures/conductor/legacy/conductor.json create mode 100644 packages/migrate/package.json create mode 100644 packages/migrate/src/cli.test.ts create mode 100644 packages/migrate/src/cli.ts create mode 100644 packages/migrate/src/migrate.test.ts create mode 100644 packages/migrate/src/migrate.ts create mode 100644 packages/migrate/src/output.ts create mode 100644 packages/migrate/src/paseo.ts create mode 100644 packages/migrate/src/sources/conductor/database.test.ts create mode 100644 packages/migrate/src/sources/conductor/database.ts create mode 100644 packages/migrate/src/sources/conductor/index.ts create mode 100644 packages/migrate/src/sources/conductor/inspect.test.ts create mode 100644 packages/migrate/src/sources/conductor/inspect.ts create mode 100644 packages/migrate/src/sources/conductor/project-config.test.ts create mode 100644 packages/migrate/src/sources/conductor/project-config.ts create mode 100644 packages/migrate/src/sources/conductor/sqlite.fixture.ts create mode 100644 packages/migrate/src/sources/index.ts create mode 100644 packages/migrate/src/types.ts create mode 100644 packages/migrate/tsconfig.json create mode 100644 packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts delete mode 100644 packages/server/src/server/session/project-config/import/merge.ts delete mode 100644 packages/server/src/server/session/project-config/import/registry.test.ts delete mode 100644 packages/server/src/server/session/project-config/import/registry.ts delete mode 100644 packages/server/src/server/session/project-config/import/service.test.ts delete mode 100644 packages/server/src/server/session/project-config/import/service.ts delete mode 100644 packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts delete mode 100644 packages/server/src/server/session/project-config/import/sources/conductor/importer.ts create mode 100644 scripts/verify-migrate-package-contents.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 234374fcd..eaf76408d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,7 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server + npm run verify:package --workspace=@getpaseo/migrate server-tests: strategy: @@ -181,6 +182,43 @@ jobs: if-no-files-found: ignore retention-days: 7 + migration-electron: + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies with retry + run: node scripts/npm-retry.mjs ci + + - name: Build app and server dependencies + run: | + npm run build:app-deps + npm run build:server + + - name: Build migrator and Desktop main + run: | + npm run build --workspace=@getpaseo/migrate + npm run build:main --workspace=@getpaseo/desktop + + - name: Run product migration behavior + run: npm run test:migration-electron --workspace=@getpaseo/desktop + + - name: Upload migration behavior diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: migration-electron-macos + path: | + packages/app/test-results/ + packages/app/playwright-report/ + retention-days: 7 + app-tests: runs-on: ubuntu-latest env: @@ -228,9 +266,38 @@ jobs: - name: Run client tests run: npm run test --workspace=@getpaseo/client + - name: Run migrator tests + run: npm run test --workspace=@getpaseo/migrate + - name: Typecheck client examples run: npm run typecheck:examples --workspace=@getpaseo/client + migrator-node18: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Build published migrator + run: | + npm run build:server-deps + npm run build --workspace=@getpaseo/migrate + + - name: Exercise the published CLI on Node 18 + run: node packages/migrate/dist/cli.js conductor --database packages/migrate/fixtures/conductor/conductor.db --dry-run + + - name: Verify published package contents + run: npm run verify:package --workspace=@getpaseo/migrate + playwright: strategy: fail-fast: false diff --git a/docs/architecture.md b/docs/architecture.md index 8ba52a3ca..a25f739d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,19 @@ it does not depend on the server. Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient` facade. App and CLI may import the low-level driver from `@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped -code imports from `@getpaseo/client`. +code imports from `@getpaseo/client`. Node automation uses +`@getpaseo/client/node`, which owns daemon discovery, authentication, transport +selection, and the capability-gated host automation facade shared by the CLI and +published migration tools. + +### `packages/migrate` — Installation migration CLI + +Published as `@getpaseo/migrate`. It inspects third-party installation metadata +through source adapters, maps source-neutral projects/config/workspaces, and +applies them through the public Node host automation client. Source knowledge and +fixtures stay in this package; the daemon and shared app workflow remain +source-neutral. Desktop bundles the exact-version CLI and exposes a narrow, +local-only Electron bridge for supported migrations. ### `packages/app` — Mobile + web client (Expo) diff --git a/docs/testing.md b/docs/testing.md index 98e41d635..b5226a987 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -146,6 +146,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials. +Desktop migration coverage starts from the real product Integrations row and crosses the real +Electron main process, compiled sandboxed preload, production migration IPC, a real +Desktop-managed daemon, and the bundled migration entrypoint. It covers confirmation, streamed +output, success, failure, and ineligible-host UI: + +```bash +npm run test:migration-electron --workspace=@getpaseo/desktop +``` + Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI. Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn. diff --git a/package-lock.json b/package-lock.json index 0a401f4cf..75fb7bfe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "packages/highlight", "packages/protocol", "packages/client", + "packages/migrate", "packages/server", "packages/app", "packages/relay", @@ -6206,6 +6207,10 @@ "resolved": "packages/highlight", "link": true }, + "node_modules/@getpaseo/migrate": { + "resolved": "packages/migrate", + "link": true + }, "node_modules/@getpaseo/protocol": { "resolved": "packages/protocol", "link": true @@ -12274,6 +12279,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -12616,6 +12628,17 @@ "@types/node": "*" } }, + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -31890,6 +31913,12 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/srvx": { "version": "0.11.15", "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz", @@ -36435,10 +36464,12 @@ "dependencies": { "@getpaseo/protocol": "0.1.110", "@getpaseo/relay": "0.1.110", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.9.0", + "@types/ws": "^8.5.14", "typescript": "^5.2.2", "vitest": "^4.1.6" } @@ -36449,6 +36480,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", + "@getpaseo/migrate": "*", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", @@ -36456,6 +36488,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.56.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "41.2.0", @@ -37814,6 +37847,25 @@ } } }, + "packages/migrate": { + "name": "@getpaseo/migrate", + "version": "0.1.110", + "dependencies": { + "@getpaseo/client": "0.1.110", + "@getpaseo/protocol": "0.1.110", + "smol-toml": "^1.6.0", + "sql.js": "^1.14.1" + }, + "bin": { + "paseo-migrate": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "@types/sql.js": "^1.4.11", + "typescript": "^5.2.2", + "vitest": "^4.1.6" + } + }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.1.110", @@ -38077,7 +38129,6 @@ "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", "sherpa-onnx-node": "1.12.28", - "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "uuid": "^9.0.1", @@ -38086,6 +38137,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@getpaseo/migrate": "0.1.110", "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", diff --git a/package.json b/package.json index c77a52058..09851c003 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "packages/highlight", "packages/protocol", "packages/client", + "packages/migrate", "packages/server", "packages/app", "packages/relay", @@ -98,11 +99,11 @@ "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next", "version:all:promote": "node scripts/set-release-version.mjs --mode promote", - "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", - "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", - "release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta", - "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", - "release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta", + "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run typecheck --workspace=@getpaseo/migrate && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/migrate", + "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public && npm publish --dry-run --workspace=@getpaseo/migrate --access public", + "release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/migrate --access public --tag beta", + "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public && npm publish --workspace=@getpaseo/migrate --access public", + "release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta && npm publish --workspace=@getpaseo/migrate --access public --tag beta", "release:push": "node scripts/push-current-release-tag.mjs", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push", diff --git a/packages/app/e2e/conductor-migration.electron.spec.ts b/packages/app/e2e/conductor-migration.electron.spec.ts new file mode 100644 index 000000000..e9e7b3a33 --- /dev/null +++ b/packages/app/e2e/conductor-migration.electron.spec.ts @@ -0,0 +1,172 @@ +import { execFileSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + _electron as electron, + expect, + test, + type ElectronApplication, + type Page, +} from "@playwright/test"; + +test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime"); + +const repoRoot = path.resolve(__dirname, "../../.."); +let installation: Awaited>; +let electronApp: ElectronApplication; + +test.beforeAll(async () => { + installation = await createConductorInstallation(); +}); + +test.afterAll(async () => { + await electronApp?.close(); + rmSync(installation.root, { recursive: true, force: true }); +}); + +test("imports from the product Integrations row and renders success, failure, and eligibility", async () => { + electronApp = await launchProduct(); + const page = await electronApp.firstWindow(); + await openIntegrations(page); + + await openMigration(page); + await expect( + page.getByText("Paseo will register valid repositories", { exact: false }), + ).toBeVisible(); + await page.getByTestId("migration-confirm").click(); + await expect(page.getByTestId("migration-result")).toHaveText("Import complete."); + await expect(page.getByTestId("migration-output")).toContainText( + `Registered project ${installation.repo}.`, + ); + await expect(page.getByTestId("migration-output")).toContainText("Migration summary:"); + + await page.getByTestId("migration-done").click(); + writeFileSync(installation.databasePath, "not a sqlite database"); + await openMigration(page); + await page.getByTestId("migration-confirm").click(); + await expect(page.getByTestId("migration-result")).toHaveText("Import failed."); + await expect(page.getByTestId("migration-output")).toContainText("ERROR:"); + + await page.getByTestId("migration-done").click(); + await stopMigrationHost(); + await page.getByRole("button", { name: "General", exact: true }).click(); + await page.getByRole("button", { name: "Integrations", exact: true }).click(); + const row = page.getByTestId("conductor-migration-row"); + await expect(row).toContainText("Start the Desktop-managed host before importing."); + await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled(); +}); + +async function launchProduct(): Promise { + const metroPort = requiredEnvironment("E2E_METRO_PORT"); + return electron.launch({ + args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")], + env: { + ...process.env, + EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`, + HOME: installation.home, + PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"), + PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1", + PASEO_TEST_APP_NAME: "Paseo Migration Behavior", + }, + }); +} + +async function openIntegrations(page: Page): Promise { + const settings = page.getByRole("button", { name: "Settings", exact: true }); + await expect(settings).toBeVisible({ timeout: 30_000 }); + await settings.click(); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await page.getByRole("button", { name: "Integrations", exact: true }).click(); + await expect(page.getByTestId("conductor-migration-row")).toBeVisible(); +} + +async function openMigration(page: Page): Promise { + const button = page + .getByTestId("conductor-migration-row") + .getByRole("button", { name: "Import", exact: true }); + await expect(button).toBeEnabled(); + await button.click(); + await expect(page.getByTestId("migration-sheet")).toBeVisible(); +} + +async function createConductorInstallation(): Promise<{ + root: string; + home: string; + repo: string; + databasePath: string; +}> { + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-migration-"))); + const home = path.join(root, "home"); + const repo = path.join(root, "repo"); + mkdirSync(home, { recursive: true }); + execFileSync("git", ["init", "-b", "main", repo]); + execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo }); + writeFileSync(path.join(repo, "README.md"), "fixture\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { + cwd: repo, + }); + + const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app"); + mkdirSync(databaseDirectory, { recursive: true }); + const databasePath = path.join(databaseDirectory, "conductor.db"); + cpSync( + path.join(repoRoot, "packages", "migrate", "fixtures", "conductor", "conductor.db"), + databasePath, + ); + execFileSync( + process.execPath, + [ + "-e", + `const fs = require("node:fs"); +const initSqlJs = require("sql.js"); +(async () => { + const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") }); + const databasePath = process.argv[1]; + const repo = process.argv[2]; + const database = new SQL.Database(fs.readFileSync(databasePath)); + database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]); + database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'"); + database.run("DELETE FROM workspaces"); + fs.writeFileSync(databasePath, database.export()); + database.close(); +})().catch((error) => { console.error(error); process.exitCode = 1; });`, + databasePath, + repo, + ], + { cwd: repoRoot }, + ); + return { root, home, repo: realpathSync(repo), databasePath }; +} + +async function stopMigrationHost(): Promise { + const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid"); + const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid; + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch { + return; + } + } + throw new Error(`Migration host ${pid} did not stop.`); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required.`); + return value; +} diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index c64b58072..c128b28e1 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -673,11 +673,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess { PASEO_HOME: args.paseoHome, PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, PASEO_SERVER_ID: "srv_e2e_test_daemon", - PASEO_LISTEN: `0.0.0.0:${args.port}`, + PASEO_LISTEN: + process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, PASEO_NODE_ENV: "development", NODE_ENV: "development", + ...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}), }); const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index a85a5dff5..6a04bbd51 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -137,7 +137,6 @@ export interface SeedDaemonClient { agentId: string; }): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; getLastServerInfoMessage(): { - serverId: string; features?: { projectAdd?: boolean; workspaceRecovery?: boolean; diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/projects-settings.spec.ts index 2cda10c8d..1669586b5 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/projects-settings.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, readFile } from "node:fs/promises"; import path from "node:path"; import { expect, test as base, type Page } from "./fixtures"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; @@ -43,7 +43,6 @@ const updatedSetup = ["npm install", "npm run build"]; interface ProjectsSettingsProject { name: string; path: string; - projectId?: string; } interface ProjectsSettingsFixtures { @@ -135,47 +134,10 @@ async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promi expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`); } -async function readProjectConfigFile( - project: Pick, -): Promise { +async function readProjectConfigFile(project: ProjectsSettingsProject): Promise { return readFile(path.join(project.path, "paseo.json"), "utf8"); } -async function writeConductorSharedToml( - project: Pick, - contents: string, -): Promise { - const conductorDir = path.join(project.path, ".conductor"); - await mkdir(conductorDir, { recursive: true }); - await writeFile(path.join(conductorDir, "settings.toml"), contents); -} - -async function openConductorImportRoute(input: { - page: Page; - projectId: string; - serverId: string; - intentId: string; -}): Promise { - const query = new URLSearchParams({ - importSource: "conductor", - importServerId: input.serverId, - importIntentId: input.intentId, - }); - await input.page.goto(`/settings/projects/${encodeURIComponent(input.projectId)}?${query}`); -} - -async function expectConductorImportPreview(page: Page): Promise { - const sheet = page.getByTestId("project-config-import-sheet"); - await expect(sheet).toBeVisible({ timeout: 30_000 }); - await expect(sheet.getByText("Will import")).toBeVisible({ timeout: 30_000 }); -} - -function getSeededServerId(serverInfo: { serverId: string } | null): string { - const serverId = serverInfo?.serverId; - expect(serverId).toBeTruthy(); - return serverId!; -} - async function addProjectFromSidebar(page: Page, projectPath: string): Promise { await openAddProjectFlow(page); await chooseAddProjectMethod(page, "directory-search"); @@ -255,144 +217,6 @@ test.describe("Projects settings", () => { }); }); -test.describe("Projects settings — Conductor project import", () => { - test("callout opens the review sheet, discloses collisions, and imports available items", async ({ - page, - }) => { - const workspace = await seedWorkspace({ - repoPrefix: "projects-settings-conductor-", - repo: { - paseoConfig: { - scripts: { - dev: { - command: "pnpm dev", - customScriptField: "preserved", - }, - }, - }, - files: [ - { - path: ".conductor/settings.toml", - content: [ - "[scripts]", - 'setup = "npm ci"', - 'run_mode = "tmux"', - "", - "[scripts.run.dev]", - 'command = "npm run dev -- --port $CONDUCTOR_PORT"', - "", - "[scripts.run.lint]", - 'command = "npm run lint"', - "", - ].join("\n"), - }, - ], - }, - }); - - try { - await gotoAppShell(page); - await page.getByRole("button", { name: "main" }).click(); - - const callout = page.getByTestId(`worktree-setup-callout-${workspace.projectId}`); - await expect(callout.getByText("Conductor setup found")).toBeVisible({ timeout: 30_000 }); - await callout.getByRole("button", { name: "Review migration" }).click(); - - await expectConductorImportPreview(page); - await expect(page.getByText("Worktree setup")).toBeVisible(); - await expect(page.getByText("Script lint")).toBeVisible(); - await expect(page.getByText("Needs attention")).toBeVisible(); - await expect(page.getByText('Paseo already has a "dev" script.')).toBeVisible(); - await expect(page.getByText("Not supported")).toBeVisible(); - await expect(page.getByText("Paseo has no project-wide run mode.")).toBeVisible(); - - await page.getByTestId("project-config-import-apply").click(); - await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible({ - timeout: 30_000, - }); - await expect(callout).not.toBeVisible({ timeout: 30_000 }); - - await expect - .poll(async () => JSON.parse(await readProjectConfigFile({ path: workspace.repoPath }))) - .toMatchObject({ - worktree: { - setup: "npm ci", - }, - scripts: { - dev: { - command: "pnpm dev", - customScriptField: "preserved", - }, - lint: { - command: "npm run lint", - }, - }, - }); - } finally { - await workspace.cleanup(); - } - }); - - test("stale source refresh reloads the preview before import", async ({ page }) => { - const workspace = await seedWorkspace({ - repoPrefix: "projects-settings-conductor-stale-", - repo: { - files: [ - { - path: ".conductor/settings.toml", - content: '[scripts]\nsetup = "npm ci"\n', - }, - ], - }, - }); - const serverId = getSeededServerId(workspace.client.getLastServerInfoMessage()); - - try { - await openConductorImportRoute({ - page, - projectId: workspace.projectId, - serverId, - intentId: "stale-source", - }); - await expectConductorImportPreview(page); - - await writeConductorSharedToml( - { path: workspace.repoPath }, - '[scripts]\nsetup = "pnpm install"\n', - ); - await page.getByTestId("project-config-import-apply").click(); - - await expect(page.getByTestId("project-config-import-error")).toContainText( - "The Conductor config changed", - { timeout: 30_000 }, - ); - await page.getByTestId("project-config-import-refresh").click(); - - await expect(page.getByText("pnpm install")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("project-config-import-error")).not.toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByTestId("project-config-import-apply")).toBeEnabled({ - timeout: 30_000, - }); - await page.getByTestId("project-config-import-apply").click(); - - await expect(page.getByTestId("project-config-import-sheet")).not.toBeVisible({ - timeout: 30_000, - }); - await expect - .poll(async () => JSON.parse(await readProjectConfigFile({ path: workspace.repoPath }))) - .toMatchObject({ - worktree: { - setup: "pnpm install", - }, - }); - } finally { - await workspace.cleanup(); - } - }); -}); - test.describe("Projects settings — error UX", () => { test("stale-write callout appears on save, disables save, and reload clears it", async ({ page, diff --git a/packages/app/package.json b/packages/app/package.json index 5eb53ba0a..5c8418de2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -23,6 +23,7 @@ "test:browser": "vitest run --project browser", "test:e2e": "playwright test --project='Desktop Chrome'", "test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts", + "test:e2e:migration-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-migration.electron.spec.ts", "test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx index d4fb095ee..5fe6f442a 100644 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ b/packages/app/src/app/settings/projects/[projectKey].tsx @@ -1,47 +1,12 @@ -import { useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useMemo } from "react"; +import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import SettingsScreen from "@/screens/settings-screen"; -import { - parseProjectConfigImportIntent, - stripProjectConfigImportSearchParams, -} from "@/project-config-import/route"; -import { projectConfigImportSourceRegistry } from "@/project-config-import/sources"; -import { isWeb } from "@/constants/platform"; export default function SettingsProjectDetailRoute() { - const router = useRouter(); - const params = useLocalSearchParams<{ - projectKey?: string | string[]; - importSource?: string | string[]; - importServerId?: string | string[]; - importIntentId?: string | string[]; - }>(); + const params = useLocalSearchParams<{ projectKey?: string | string[] }>(); const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey; const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : ""; - const importIntent = useMemo( - () => parseProjectConfigImportIntent(params, projectConfigImportSourceRegistry), - [params], - ); - const handleImportIntentConsumed = useCallback(() => { - router.setParams({ - importSource: undefined, - importServerId: undefined, - importIntentId: undefined, - }); - if (isWeb && typeof window !== "undefined") { - const route = `${window.location.pathname}${window.location.search}${window.location.hash}`; - window.history.replaceState(null, "", stripProjectConfigImportSearchParams(route)); - } - }, [router]); - const view = useMemo( - () => ({ - kind: "project" as const, - projectKey, - importIntent, - onImportIntentConsumed: handleImportIntentConsumed, - }), - [handleImportIntentConsumed, importIntent, projectKey], - ); + const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]); return ; } diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index d92cfdf41..03257be24 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildProjectConfigImportCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -101,49 +100,4 @@ describe("buildWorktreeSetupCalloutPolicy", () => { testID: "worktree-setup-callout-project-1", }); }); - - it("builds a host-bound import route with a single-use intent", () => { - expect( - buildProjectConfigImportCalloutPolicy( - { - serverId: "server-1", - projectKey: "remote:github.com/acme/app", - repoRoot: "/repo/project-1", - }, - { - status: "one", - sourceDisplayName: "Fake Source", - sourceRouteValue: "fake", - intentId: "intent-1", - }, - ), - ).toEqual({ - id: "worktree-setup-missing:remote:github.com/acme/app", - dismissalKey: "worktree-setup-missing:remote:github.com/acme/app", - priority: 100, - title: "Fake Source setup found", - description: "Import workspace setup and run scripts from Fake Source.", - actionLabel: "Review migration", - projectSettingsRoute: - "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=fake&importServerId=server-1&importIntentId=intent-1", - testID: "worktree-setup-callout-remote:github.com/acme/app", - }); - }); - - it("routes many import sources to project settings without selecting a source", () => { - expect( - buildProjectConfigImportCalloutPolicy( - { - serverId: "server-1", - projectKey: "remote:github.com/acme/app", - repoRoot: "/repo/project-1", - }, - { status: "many" }, - ), - ).toMatchObject({ - title: "Project setup imports found", - description: "Review available project setup imports in Project Settings.", - projectSettingsRoute: "/settings/projects/remote%3Agithub.com%2Facme%2Fapp", - }); - }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 4ec2d8126..371af8f22 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -1,6 +1,6 @@ import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; import { i18n } from "@/i18n/i18next"; -import { buildProjectSettingsImportRoute, buildProjectSettingsRoute } from "@/utils/host-routes"; +import { buildProjectSettingsRoute } from "@/utils/host-routes"; export interface WorktreeSetupWorkspaceInput { projectId: string; @@ -73,50 +73,6 @@ export function buildWorktreeSetupCalloutPolicy( }; } -export function buildProjectConfigImportCalloutPolicy( - project: ActiveGitWorkspaceProject, - input: - | { - status: "one"; - sourceDisplayName: string; - sourceRouteValue: string; - intentId: string; - } - | { status: "many" }, -): WorktreeSetupCalloutPolicy { - const calloutKey = `worktree-setup-missing:${project.projectKey}`; - const title = - input.status === "one" - ? i18n.t("sidebar.worktreeSetup.importTitle", { source: input.sourceDisplayName }) - : i18n.t("sidebar.worktreeSetup.importManyTitle"); - const description = - input.status === "one" - ? i18n.t("sidebar.worktreeSetup.importDescription", { - source: input.sourceDisplayName, - }) - : i18n.t("sidebar.worktreeSetup.importManyDescription"); - const projectSettingsRoute = - input.status === "one" - ? buildProjectSettingsImportRoute({ - projectKey: project.projectKey, - source: input.sourceRouteValue, - serverId: project.serverId, - intentId: input.intentId, - }) - : buildProjectSettingsRoute(project.projectKey); - - return { - id: calloutKey, - dismissalKey: calloutKey, - priority: 100, - title, - description, - actionLabel: i18n.t("sidebar.worktreeSetup.reviewMigration"), - projectSettingsRoute, - testID: `worktree-setup-callout-${project.projectKey}`, - }; -} - function hasSetupCommands(config: PaseoConfigRaw): boolean { const setup = config.worktree?.setup; if (typeof setup === "string") { diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index b566c643b..2cba8dfe6 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -3,12 +3,10 @@ import { useRouter } from "expo-router"; import { useEffect, useMemo } from "react"; import { useSidebarCallouts } from "@/contexts/sidebar-callout-context"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { useProjectConfigImportAvailability } from "@/project-config-import/use-project-config-import-model"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { - buildProjectConfigImportCalloutPolicy, buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, @@ -24,11 +22,12 @@ export function WorktreeSetupCalloutSource() { const client = useHostRuntimeClient(activeProject?.serverId ?? ""); const callouts = useSidebarCallouts(); const router = useRouter(); - const openProjectSettings = useStableEvent( - (route: ReturnType["projectSettingsRoute"]) => { - router.navigate(route); - }, - ); + const openProjectSettings = useStableEvent(() => { + if (!activeProject) { + return; + } + router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute); + }); const readQuery = useQuery({ queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""], @@ -42,33 +41,13 @@ export function WorktreeSetupCalloutSource() { retry: false, }); - const shouldConsiderSetup = activeProject && shouldShowWorktreeSetupCallout(readQuery.data); - const importAvailability = useProjectConfigImportAvailability({ - client, - serverId: activeProject?.serverId, - repoRoot: activeProject?.repoRoot, - enabled: Boolean(shouldConsiderSetup), - }); - const calloutPolicy = useMemo(() => { - if (!activeProject || !shouldShowWorktreeSetupCallout(readQuery.data)) { - return null; - } - if (importAvailability.status === "loading") { - return null; - } - if (importAvailability.status === "one" && importAvailability.source) { - return buildProjectConfigImportCalloutPolicy(activeProject, { - status: "one", - sourceDisplayName: importAvailability.source.module.displayName, - sourceRouteValue: importAvailability.source.module.routeValue, - intentId: String(Date.now()), - }); - } - if (importAvailability.status === "many") { - return buildProjectConfigImportCalloutPolicy(activeProject, { status: "many" }); - } - return buildWorktreeSetupCalloutPolicy(activeProject); - }, [activeProject, importAvailability.source, importAvailability.status, readQuery.data]); + const calloutPolicy = useMemo( + () => + activeProject && shouldShowWorktreeSetupCallout(readQuery.data) + ? buildWorktreeSetupCalloutPolicy(activeProject) + : null, + [activeProject, readQuery.data], + ); useEffect(() => { if (!calloutPolicy) { @@ -82,11 +61,7 @@ export function WorktreeSetupCalloutSource() { title: calloutPolicy.title, description: calloutPolicy.description, actions: [ - { - label: calloutPolicy.actionLabel, - onPress: () => openProjectSettings(calloutPolicy.projectSettingsRoute), - variant: "primary", - }, + { label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" }, ], testID: calloutPolicy.testID, }); diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index fd2eed701..003323719 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -24,13 +24,12 @@ type ReplicaQueryInput pushEvent: string; }; -export type FetchQueryInput = Omit< +type FetchQueryInput = Omit< UseQueryOptions, "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; - refetchOnMount?: UseQueryOptions["refetchOnMount"]; staleTimeMs: number; }; @@ -85,7 +84,7 @@ function replicaQueryOptions< }; } -export function fetchQueryOptions< +function fetchQueryOptions< TQueryFnData, TError = Error, TData = TQueryFnData, @@ -97,7 +96,7 @@ export function fetchQueryOptions< throw new Error("Fetch queries must declare a finite staleTimeMs."); } - const { dataShape, meta, refetchOnMount, staleTimeMs, ...options } = input; + const { dataShape, meta, staleTimeMs, ...options } = input; return { ...options, ...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}), @@ -108,7 +107,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: refetchOnMount ?? "always", + refetchOnMount: "always", staleTime: staleTimeMs, }; } diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 531172eb3..bedcb669c 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -16,6 +16,7 @@ import { type SkillsStatus, } from "@/desktop/daemon/desktop-daemon"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; +import { ConductorMigration } from "@/desktop/migrations/conductor"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; @@ -203,6 +204,7 @@ export function IntegrationsSection() { onUninstall={handleUninstallSkills} /> + ); diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index cb750af12..88493bce9 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -165,6 +165,30 @@ export interface DesktopInvokeBridge { invoke?: (command: string, args?: Record) => Promise; } +export interface DesktopMigrationOutput { + runId: string; + stream: "stdout" | "stderr" | "status"; + chunk?: string; + exitCode?: number; +} + +export interface DesktopMigrationsBridge { + getAvailability?: (input: { source: string }) => Promise<{ + available: boolean; + reason: + | "unsupported-source" + | "host-not-running" + | "nonlocal-host" + | "password-protected" + | "host-version-mismatch" + | "migrator-version-mismatch" + | "unavailable" + | null; + }>; + run?: (input: { source: string }) => Promise<{ runId: string }>; + onOutput?: (handler: (output: DesktopMigrationOutput) => void) => () => void; +} + export interface DesktopHostBridge { platform?: string; invoke?: DesktopInvokeBridge["invoke"]; @@ -178,6 +202,7 @@ export interface DesktopHostBridge { webUtils?: DesktopWebUtilsBridge; menu?: DesktopMenuBridge; browser?: DesktopBrowserBridge; + migrations?: DesktopMigrationsBridge; } declare global { diff --git a/packages/app/src/desktop/migrations/conductor.svg b/packages/app/src/desktop/migrations/conductor.svg new file mode 100644 index 000000000..9d43c44ad --- /dev/null +++ b/packages/app/src/desktop/migrations/conductor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app/src/desktop/migrations/conductor.tsx b/packages/app/src/desktop/migrations/conductor.tsx new file mode 100644 index 000000000..8f8ca340f --- /dev/null +++ b/packages/app/src/desktop/migrations/conductor.tsx @@ -0,0 +1,64 @@ +import { Image, Text, View } from "react-native"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { isElectronRuntimeMac } from "@/desktop/host"; +import { + MigrationSheet, + type MigrationSourceDescriptor, + useMigrationAvailability, +} from "./migration-sheet"; + +const conductorSource = { + id: "conductor", + icon: require("./conductor.svg"), +}; + +export function ConductorMigration() { + const { t } = useTranslation(); + const source = useMemo( + () => ({ + ...conductorSource, + title: t("desktop.integrations.migration.conductor.title"), + description: t("desktop.integrations.migration.conductor.description"), + sheetTitle: t("desktop.integrations.migration.conductor.sheetTitle"), + confirmation: t("desktop.integrations.migration.conductor.confirmation"), + }), + [t], + ); + const migration = useMigrationAvailability(source.id); + if (!isElectronRuntimeMac()) return null; + + return ( + <> + + + + + {source.title} + + {migration.reason ?? source.description} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + icon: { width: theme.iconSize.md, height: theme.iconSize.md }, +})); + +const conductorRowStyle = [settingsStyles.row, settingsStyles.rowBorder]; diff --git a/packages/app/src/desktop/migrations/migration-sheet.tsx b/packages/app/src/desktop/migrations/migration-sheet.tsx new file mode 100644 index 000000000..dd169c59b --- /dev/null +++ b/packages/app/src/desktop/migrations/migration-sheet.tsx @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ImageSourcePropType } from "react-native"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { getDesktopHost, type DesktopMigrationOutput } from "@/desktop/host"; + +type MigrationState = + | { status: "confirm" } + | { status: "running"; runId: string | null; output: string } + | { status: "complete"; succeeded: boolean; output: string } + | { status: "failed"; message: string; output: string }; + +export interface MigrationSourceDescriptor { + id: string; + icon: ImageSourcePropType; + title: string; + description: string; + sheetTitle: string; + confirmation: string; +} + +export function useMigrationAvailability(source: string) { + const { t } = useTranslation(); + const [availability, setAvailability] = useState<{ + available: boolean; + reason: string | null; + }>({ + available: false, + reason: t("desktop.integrations.migration.availability.checking"), + }); + const [visible, setVisible] = useState(false); + useEffect(() => { + let active = true; + async function loadAvailability() { + const next = await getDesktopHost()?.migrations?.getAvailability?.({ source }); + if (active && next) { + setAvailability({ + available: next.available, + reason: next.reason + ? t(`desktop.integrations.migration.availability.${next.reason}`) + : null, + }); + } + } + void loadAvailability(); + return () => { + active = false; + }; + }, [source, t]); + return { + ...availability, + visible, + open: () => setVisible(true), + close: () => setVisible(false), + }; +} + +export function MigrationSheet({ + source, + visible, + onClose, +}: { + source: MigrationSourceDescriptor; + visible: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [state, setState] = useState({ status: "confirm" }); + const header = useMemo(() => ({ title: source.sheetTitle }), [source.sheetTitle]); + + useEffect(() => { + if (!visible) setState({ status: "confirm" }); + }, [visible]); + + const start = useCallback(async () => { + const bridge = getDesktopHost()?.migrations; + if (!bridge?.run || !bridge.onOutput) { + setState({ + status: "failed", + message: t("desktop.integrations.migration.unavailable"), + output: "", + }); + return; + } + setState({ status: "running", runId: null, output: "" }); + let runId: string | null = null; + const unsubscribe = bridge.onOutput((event: DesktopMigrationOutput) => { + if (runId && event.runId !== runId) return; + setState((current) => reduceOutput(current, event)); + if (event.stream === "status") unsubscribe(); + }); + try { + const started = await bridge.run({ source: source.id }); + runId = started.runId; + setState((current) => + current.status === "running" ? { ...current, runId: started.runId } : current, + ); + } catch (error) { + unsubscribe(); + setState({ + status: "failed", + message: error instanceof Error ? error.message : String(error), + output: "", + }); + } + }, [source.id, t]); + + const footer = useMemo(() => { + if (state.status === "confirm") { + return ( + + ); + } + if (state.status === "running") { + return ; + } + return ( + + ); + }, [onClose, start, state.status, t]); + + return ( + + {state.status === "confirm" ? ( + {source.confirmation} + ) : ( + + {state.status === "complete" ? ( + + {state.succeeded + ? t("desktop.integrations.migration.complete") + : t("desktop.integrations.migration.failed")} + + ) : null} + {state.status === "failed" ? ( + + {state.message} + + ) : null} + + + {state.output} + + + + )} + + ); +} + +function reduceOutput(state: MigrationState, event: DesktopMigrationOutput): MigrationState { + if (state.status !== "running") return state; + if (event.stream === "status") { + return { status: "complete", succeeded: event.exitCode === 0, output: state.output }; + } + return { ...state, output: `${state.output}${event.chunk ?? ""}` }; +} + +const styles = StyleSheet.create((theme) => ({ + content: { gap: theme.spacing[3] }, + copy: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, + error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm }, + output: { + minHeight: 180, + maxHeight: 360, + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + padding: theme.spacing[3], + }, + outputText: { color: theme.colors.foregroundMuted, fontFamily: "monospace", fontSize: 12 }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9f3ca031b..dd3ad073b 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -867,11 +867,6 @@ export const ar: TranslationResources = { description: "أضف أوامر الإعداد حتى تتمكن أشجار العمل الجديدة من تثبيت التبعيات وإعداد نفسها تلقائيًا.", openProjectSettings: "افتح إعدادات المشروع", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1119,6 +1114,29 @@ export const ar: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "جارٍ التحقق من توفّر الترحيل…", + "unsupported-source": "مصدر الترحيل هذا غير متاح.", + "host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.", + "nonlocal-host": "الاستيراد غير متاح لمضيف غير محلي.", + "password-protected": "الاستيراد غير متاح أثناء حماية المضيف المحلي بكلمة مرور.", + "host-version-mismatch": "حدّث المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.", + "migrator-version-mismatch": "أداة الترحيل المضمّنة لا تطابق إصدار تطبيق سطح المكتب هذا.", + unavailable: "ترحيل تطبيق سطح المكتب غير متاح.", + }, + actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" }, + unavailable: "الترحيل عبر تطبيق سطح المكتب غير متاح.", + complete: "اكتمل الاستيراد.", + failed: "فشل الاستيراد.", + conductor: { + title: "Conductor", + description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.", + sheetTitle: "الاستيراد من Conductor", + confirmation: + "سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات Conductor.", + }, + }, cli: { statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.", installFailed: "غير قادر على تثبيت PaseoCLI.", @@ -2069,30 +2087,6 @@ export const ar: TranslationResources = { teardown: "هدم", teardownAccessibility: "أوامر هدم شجرة العمل", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "البرامج النصية", info: "خدمات طويلة الأمد وأوامر لمرة واحدة يمكنك إطلاقها من أي وكيل في هذا المشروع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a3d68f45a..e388b2cdf 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -878,11 +878,6 @@ export const en = { description: "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", openProjectSettings: "Open project settings", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1130,6 +1125,29 @@ export const en = { }, }, integrations: { + migration: { + availability: { + checking: "Checking migration availability…", + "unsupported-source": "This migration source is unavailable.", + "host-not-running": "Start the Desktop-managed host before importing.", + "nonlocal-host": "Import is unavailable for a nonlocal host.", + "password-protected": "Import is unavailable while the local host is password-protected.", + "host-version-mismatch": "Update the Desktop-managed host before importing.", + "migrator-version-mismatch": "The bundled migrator does not match this Desktop version.", + unavailable: "Desktop migration is unavailable.", + }, + actions: { import: "Import", importing: "Importing…", done: "Done" }, + unavailable: "Desktop migration is unavailable.", + complete: "Import complete.", + failed: "Import failed.", + conductor: { + title: "Conductor", + description: "Import projects, settings, and worktrees from this Mac.", + sheetTitle: "Import from Conductor", + confirmation: + "Paseo will register valid repositories, merge supported project settings, and adopt or recreate ready worktrees. Conductor data will not be changed.", + }, + }, cli: { statusFailed: "Unable to check CLI install status.", installFailed: "Unable to install the Paseo CLI.", @@ -2083,30 +2101,6 @@ export const en = { teardown: "Teardown", teardownAccessibility: "Worktree teardown commands", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Scripts", info: "Long-running services and one-off commands you can launch from any agent in this project", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index aa8c7bddd..558ea8a31 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -898,11 +898,6 @@ export const es: TranslationResources = { description: "Agregue comandos de configuración para que los nuevos árboles de trabajo puedan instalar dependencias y prepararse automáticamente.", openProjectSettings: "Abrir la configuración del proyecto", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1159,6 +1154,31 @@ export const es: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "Comprobando la disponibilidad de la migración…", + "unsupported-source": "Esta fuente de migración no está disponible.", + "host-not-running": "Inicia el host gestionado por Desktop antes de importar.", + "nonlocal-host": "La importación no está disponible para un host no local.", + "password-protected": + "La importación no está disponible mientras el host local esté protegido con contraseña.", + "host-version-mismatch": "Actualiza el host gestionado por Desktop antes de importar.", + "migrator-version-mismatch": + "El migrador incluido no coincide con esta versión de Desktop.", + unavailable: "La migración de Desktop no está disponible.", + }, + actions: { import: "Importar", importing: "Importando…", done: "Listo" }, + unavailable: "La migración de escritorio no está disponible.", + complete: "Importación completada.", + failed: "La importación ha fallado.", + conductor: { + title: "Conductor", + description: "Importa proyectos, ajustes y worktrees desde este Mac.", + sheetTitle: "Importar desde Conductor", + confirmation: + "Paseo registrará los repositorios válidos, combinará los ajustes de proyecto compatibles y adoptará o recreará los worktrees preparados. Los datos de Conductor no se modificarán.", + }, + }, cli: { statusFailed: "No se puede verificar el estado de instalación deCLI.", installFailed: "No se puede instalar elPaseoCLI.", @@ -2121,30 +2141,6 @@ export const es: TranslationResources = { teardown: "Demoler", teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Scripts", info: "Servicios de larga duración y comandos únicos que puede iniciar desde cualquier agente en este proyecto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d7b9b1296..de7105c6b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -896,11 +896,6 @@ export const fr: TranslationResources = { description: "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1160,6 +1155,31 @@ export const fr: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "Vérification de la disponibilité de la migration…", + "unsupported-source": "Cette source de migration est indisponible.", + "host-not-running": "Démarrez l’hôte géré par Desktop avant l’importation.", + "nonlocal-host": "L’importation est indisponible pour un hôte distant.", + "password-protected": + "L’importation est indisponible tant que l’hôte local est protégé par mot de passe.", + "host-version-mismatch": "Mettez à jour l’hôte géré par Desktop avant l’importation.", + "migrator-version-mismatch": + "Le migrateur intégré ne correspond pas à cette version de Desktop.", + unavailable: "La migration Desktop est indisponible.", + }, + actions: { import: "Importer", importing: "Importation…", done: "Terminé" }, + unavailable: "La migration depuis l’application de bureau est indisponible.", + complete: "Importation terminée.", + failed: "Échec de l’importation.", + conductor: { + title: "Conductor", + description: "Importez les projets, réglages et worktrees depuis ce Mac.", + sheetTitle: "Importer depuis Conductor", + confirmation: + "Paseo enregistrera les dépôts valides, fusionnera les réglages de projet compatibles et adoptera ou recréera les worktrees prêts. Les données de Conductor ne seront pas modifiées.", + }, + }, cli: { statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", installFailed: "Impossible d'installer lePaseoCLI.", @@ -2124,30 +2144,6 @@ export const fr: TranslationResources = { teardown: "Démolir", teardownAccessibility: "Commandes de démontage de Worktree", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Scripts", info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f670cd627..6acaf9f11 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -880,11 +880,6 @@ export const ja: TranslationResources = { description: "新しいワークツリーが依存関係をインストールして自動的に準備できるようにセットアップコマンドを追加してください。", openProjectSettings: "プロジェクト設定を開く", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1134,6 +1129,30 @@ export const ja: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "移行が利用可能か確認しています…", + "unsupported-source": "この移行元は利用できません。", + "host-not-running": "インポートする前にDesktop管理ホストを起動してください。", + "nonlocal-host": "ローカル以外のホストではインポートできません。", + "password-protected": + "ローカルホストがパスワード保護されている間はインポートできません。", + "host-version-mismatch": "インポートする前にDesktop管理ホストを更新してください。", + "migrator-version-mismatch": "同梱の移行ツールはこのDesktopバージョンと一致しません。", + unavailable: "Desktop移行は利用できません。", + }, + actions: { import: "インポート", importing: "インポート中…", done: "完了" }, + unavailable: "デスクトップ移行は利用できません。", + complete: "インポートが完了しました。", + failed: "インポートに失敗しました。", + conductor: { + title: "Conductor", + description: "このMacからプロジェクト、設定、worktreeをインポートします。", + sheetTitle: "Conductorからインポート", + confirmation: + "Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。Conductorのデータは変更されません。", + }, + }, cli: { statusFailed: "CLIのインストール状態を確認できません。", installFailed: "Paseo CLIをインストールできません。", @@ -2094,30 +2113,6 @@ export const ja: TranslationResources = { teardown: "削除時", teardownAccessibility: "ワークツリー削除時のコマンド", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "スクリプト", info: "このプロジェクトのどのエージェントからでも起動できる、長時間実行サービスと単発コマンド", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2a9b67c0b..5d2af125e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -890,11 +890,6 @@ export const ptBR: TranslationResources = { description: "Adicione comandos de configuração para que novos worktrees instalem dependências e se preparem automaticamente.", openProjectSettings: "Abrir configurações do projeto", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1146,6 +1141,31 @@ export const ptBR: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "Verificando a disponibilidade da migração…", + "unsupported-source": "Esta fonte de migração não está disponível.", + "host-not-running": "Inicie o host gerenciado pelo Desktop antes de importar.", + "nonlocal-host": "A importação não está disponível para um host não local.", + "password-protected": + "A importação não está disponível enquanto o host local estiver protegido por senha.", + "host-version-mismatch": "Atualize o host gerenciado pelo Desktop antes de importar.", + "migrator-version-mismatch": + "O migrador incluído não corresponde a esta versão do Desktop.", + unavailable: "A migração do Desktop não está disponível.", + }, + actions: { import: "Importar", importing: "Importando…", done: "Concluído" }, + unavailable: "A migração pelo aplicativo para desktop não está disponível.", + complete: "Importação concluída.", + failed: "Falha na importação.", + conductor: { + title: "Conductor", + description: "Importe projetos, configurações e worktrees deste Mac.", + sheetTitle: "Importar do Conductor", + confirmation: + "O Paseo registrará repositórios válidos, mesclará configurações de projeto compatíveis e adotará ou recriará worktrees prontos. Os dados do Conductor não serão alterados.", + }, + }, cli: { statusFailed: "Não foi possível verificar o status de instalação da CLI.", installFailed: "Não foi possível instalar a CLI do Paseo.", @@ -2107,30 +2127,6 @@ export const ptBR: TranslationResources = { teardown: "Desmontagem", teardownAccessibility: "Comandos de desmontagem do worktree", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Scripts", info: "Serviços contínuos e comandos avulsos que você pode iniciar de qualquer agente neste projeto", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 537f8db2f..7206c5add 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -889,11 +889,6 @@ export const ru: TranslationResources = { description: "Добавьте команды настройки, чтобы новые рабочие деревья могли автоматически устанавливать зависимости и готовиться.", openProjectSettings: "Открыть настройки проекта", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1149,6 +1144,29 @@ export const ru: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "Проверка доступности миграции…", + "unsupported-source": "Этот источник миграции недоступен.", + "host-not-running": "Запустите управляемый Desktop хост перед импортом.", + "nonlocal-host": "Импорт недоступен для нелокального хоста.", + "password-protected": "Импорт недоступен, пока локальный хост защищён паролем.", + "host-version-mismatch": "Обновите управляемый Desktop хост перед импортом.", + "migrator-version-mismatch": "Встроенный мигратор не соответствует этой версии Desktop.", + unavailable: "Миграция Desktop недоступна.", + }, + actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" }, + unavailable: "Миграция в настольном приложении недоступна.", + complete: "Импорт завершён.", + failed: "Не удалось выполнить импорт.", + conductor: { + title: "Conductor", + description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.", + sheetTitle: "Импорт из Conductor", + confirmation: + "Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные Conductor не изменятся.", + }, + }, cli: { statusFailed: "Невозможно проверить статус установки CLI.", installFailed: "Невозможно установить PaseoCLI.", @@ -2112,30 +2130,6 @@ export const ru: TranslationResources = { teardown: "Срывать", teardownAccessibility: "Команды разрушения рабочего дерева", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Скрипты", info: "Долгоработающие службы и одноразовые команды, которые можно запускать из любого агента в этом проекте.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 95a3fbe83..d6cfacd21 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -860,11 +860,6 @@ export const zhCN: TranslationResources = { title: "设置 worktree scripts", description: "添加 setup 命令,让新的 worktree 自动安装依赖并完成准备。", openProjectSettings: "打开 project 设置", - importTitle: "{{source}} setup found", - importDescription: "Import workspace setup and run scripts from {{source}}.", - importManyTitle: "Project setup imports found", - importManyDescription: "Review available project setup imports in Project Settings.", - reviewMigration: "Review migration", }, project: { actions: { @@ -1105,6 +1100,29 @@ export const zhCN: TranslationResources = { }, }, integrations: { + migration: { + availability: { + checking: "正在检查迁移是否可用…", + "unsupported-source": "此迁移来源不可用。", + "host-not-running": "请先启动由桌面端管理的主机再导入。", + "nonlocal-host": "非本地主机无法使用导入。", + "password-protected": "本地主机受密码保护时无法导入。", + "host-version-mismatch": "请先更新由桌面端管理的主机再导入。", + "migrator-version-mismatch": "内置迁移工具与当前桌面端版本不匹配。", + unavailable: "桌面端迁移不可用。", + }, + actions: { import: "导入", importing: "正在导入…", done: "完成" }, + unavailable: "桌面端迁移不可用。", + complete: "导入完成。", + failed: "导入失败。", + conductor: { + title: "Conductor", + description: "从这台 Mac 导入项目、设置和工作树。", + sheetTitle: "从 Conductor 导入", + confirmation: + "Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。Conductor 数据不会被修改。", + }, + }, cli: { statusFailed: "无法检查 CLI 安装状态。", installFailed: "无法安装 Paseo CLI。", @@ -2045,30 +2063,6 @@ export const zhCN: TranslationResources = { teardown: "Teardown", teardownAccessibility: "Worktree teardown 命令", }, - import: { - rowTitle: "Import from {{source}}", - rowDescription: - "Review workspace setup and run scripts from {{source}} before writing paseo.json.", - sheetTitle: "Import from {{source}}", - sources: "Source files", - willImport: "Will import", - needsAttention: "Needs attention", - notSupported: "Not supported", - import: "Import", - importing: "Importing...", - refreshPreview: "Refresh preview", - success: "{{source}} settings imported", - errorTitle: "Couldn't import settings", - errors: { - capabilityMissing: "Update the host to use this.", - unsavedChanges: "Save or discard your project changes before importing.", - notFound: "No {{source}} project config was found.", - invalid: "{{path}} couldn't be parsed.", - staleSource: "The {{source}} config changed. Refresh the preview before importing.", - staleProject: "paseo.json changed. Refresh the preview before importing.", - nothing: "There is nothing new to import.", - }, - }, scripts: { title: "Scripts", info: "可从此 Project 中任意 Agent 启动的长期服务和一次性命令", diff --git a/packages/app/src/project-config-import/availability.ts b/packages/app/src/project-config-import/availability.ts deleted file mode 100644 index 6e47208d0..000000000 --- a/packages/app/src/project-config-import/availability.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - ProjectConfigImportPreview, - ProjectConfigRpcError, -} from "@getpaseo/protocol/messages"; - -export type ProjectConfigImportAvailabilityStatus = "loading" | "none" | "one" | "many"; - -type ProjectConfigImportOpenablePreview = - | ({ ok: true } & Pick) - | { ok: false; error: Pick }; - -export function projectConfigImportAvailabilityStatus(input: { - availableCount: number; - isLoading: boolean; -}): ProjectConfigImportAvailabilityStatus { - if (input.isLoading) { - return "loading"; - } - if (input.availableCount === 0) { - return "none"; - } - return input.availableCount === 1 ? "one" : "many"; -} - -export function projectConfigImportPreviewIsOpenable( - preview: ProjectConfigImportOpenablePreview | null | undefined, -): boolean { - if (!preview) { - return false; - } - if (!preview.ok) { - return preview.error.code === "invalid_source_config"; - } - if (preview.status === "available") { - return true; - } - if (preview.status === "nothing_to_import") { - return preview.items.length > 0; - } - return false; -} diff --git a/packages/app/src/project-config-import/preview-cache.ts b/packages/app/src/project-config-import/preview-cache.ts deleted file mode 100644 index 99413eaab..000000000 --- a/packages/app/src/project-config-import/preview-cache.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; -import type { QueryKey } from "@tanstack/react-query"; -import type { FetchQueryInput } from "@/data/query"; -import type { ProjectConfigImportSourceDescriptor } from "./sources"; - -export type ProjectConfigImportPreviewResult = Awaited< - ReturnType ->; - -type ProjectConfigImportPreviewQueryKey = QueryKey; - -const PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS = 5_000; - -export function projectConfigImportPreviewQueryInput(input: { - client: Pick | null; - serverId: string; - repoRoot: string; - source: ProjectConfigImportSourceDescriptor | null; - protocolSource: ProjectConfigImportSource | null; - enabled: boolean; -}): FetchQueryInput< - ProjectConfigImportPreviewResult, - Error, - ProjectConfigImportPreviewResult, - ProjectConfigImportPreviewQueryKey -> { - return { - queryKey: projectConfigImportPreviewQueryKey(input.serverId, input.repoRoot, input.source), - queryFn: () => { - if (!input.client || !input.protocolSource) { - throw new Error("Project config import preview requires a daemon client and source"); - } - return input.client.getProjectConfigImport({ - repoRoot: input.repoRoot, - source: input.protocolSource, - }); - }, - enabled: - input.enabled && - Boolean(input.client && input.serverId && input.repoRoot && input.protocolSource), - refetchOnMount: true, - retry: false, - staleTimeMs: PROJECT_CONFIG_IMPORT_PREVIEW_STALE_MS, - dataShape: "value", - }; -} - -export function projectConfigImportPreviewQueryKey( - serverId: string, - repoRoot: string, - source: ProjectConfigImportSourceDescriptor | null, -): ProjectConfigImportPreviewQueryKey { - return [ - ...projectConfigImportPreviewQueryRoot(serverId, repoRoot), - source ? stableProjectConfigImportSourceKey(source) : "none", - ] as const; -} - -export function projectConfigImportPreviewQueryRoot(serverId: string, repoRoot: string) { - return ["project-config-import", serverId, repoRoot] as const; -} - -export function stableProjectConfigImportSourceKey( - source: ProjectConfigImportSourceDescriptor, -): string { - return stableJson(source); -} - -function stableJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(stableJson).join(",")}]`; - } - if (value && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value); -} diff --git a/packages/app/src/project-config-import/project-config-import-model.test.ts b/packages/app/src/project-config-import/project-config-import-model.test.ts deleted file mode 100644 index 3203b6734..000000000 --- a/packages/app/src/project-config-import/project-config-import-model.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; -import { - ProjectConfigImportSourceSchema, - type ProjectConfigImportPreview, - type ProjectConfigImportSource, -} from "@getpaseo/protocol/messages"; -import { fetchQueryOptions } from "@/data/query"; -import { - projectConfigImportPreviewQueryInput, - projectConfigImportPreviewQueryKey, - projectConfigImportPreviewQueryRoot, - stableProjectConfigImportSourceKey, -} from "./preview-cache"; -import { - createProjectConfigImportIntentFromRegistration, - parseProjectConfigImportIntent, - stripProjectConfigImportSearchParams, -} from "./route"; -import { projectConfigImportApplyFailureRetryAction } from "./retry"; -import { - projectConfigImportAvailabilityStatus, - projectConfigImportPreviewIsOpenable, -} from "./availability"; -import { - createProjectConfigImportSourceRegistry, - type ProjectConfigImportSourceDescriptor, -} from "./sources"; - -interface FakeImportSource extends ProjectConfigImportSourceDescriptor { - kind: "fake-source"; - profile: "alpha"; -} -const fakeSource: FakeImportSource = { kind: "fake-source", profile: "alpha" }; -const protocolSource: ProjectConfigImportSource = ProjectConfigImportSourceSchema.options[0].parse({ - kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value, -}); - -const registry = createProjectConfigImportSourceRegistry([ - { - kind: fakeSource.kind, - source: fakeSource, - displayName: "Test Source", - routeValue: "test-source", - }, -]); - -function parseFakeSource(source: ProjectConfigImportSourceDescriptor): FakeImportSource | null { - return source.kind === fakeSource.kind ? fakeSource : null; -} - -describe("project config import intent", () => { - it("parses a host-bound route intent through the source registry", () => { - expect( - parseProjectConfigImportIntent( - { - importSource: "test-source", - importServerId: "server-1", - importIntentId: "intent-1", - }, - registry, - parseFakeSource, - ), - ).toEqual({ - serverId: "server-1", - source: fakeSource, - protocolSource: fakeSource, - intentId: "intent-1", - }); - }); - - it("rejects missing or unknown sources", () => { - expect( - parseProjectConfigImportIntent( - { - importSource: "other", - importServerId: "server-1", - importIntentId: "intent-1", - }, - registry, - parseFakeSource, - ), - ).toBeNull(); - expect( - parseProjectConfigImportIntent({ importSource: "test-source" }, registry, parseFakeSource), - ).toBeNull(); - }); - - it("uses the supplied registry instead of a hardcoded route map", () => { - const alternateRegistry = createProjectConfigImportSourceRegistry([ - { - kind: fakeSource.kind, - source: fakeSource, - displayName: "Fake Second", - routeValue: "fake", - }, - ]); - - expect( - parseProjectConfigImportIntent( - { - importSource: "fake", - importServerId: "server-1", - importIntentId: "intent-1", - }, - alternateRegistry, - parseFakeSource, - ), - ).toEqual({ - serverId: "server-1", - source: fakeSource, - protocolSource: fakeSource, - intentId: "intent-1", - }); - }); - - it("strips consumed import params from browser routes", () => { - expect( - stripProjectConfigImportSearchParams( - "/settings/projects/repo?keep=yes&importSource=conductor&importServerId=host&importIntentId=1#section", - ), - ).toBe("/settings/projects/repo?keep=yes#section"); - }); -}); - -describe("project config import retries", () => { - it("refreshes the preview when the source disappears during apply", () => { - expect( - projectConfigImportApplyFailureRetryAction({ - code: "source_config_not_found", - source: protocolSource, - }), - ).toBe("refresh"); - }); - - it("refreshes the preview after an apply-time source parse failure", () => { - expect( - projectConfigImportApplyFailureRetryAction({ - code: "invalid_source_config", - source: protocolSource, - relativePath: ".conductor/settings.toml", - }), - ).toBe("refresh"); - }); -}); - -describe("project config import preview cache keys", () => { - it("groups source previews under a repository cache root", () => { - expect(projectConfigImportPreviewQueryKey("server", "/repo", fakeSource)).toEqual([ - ...projectConfigImportPreviewQueryRoot("server", "/repo"), - stableProjectConfigImportSourceKey(fakeSource), - ]); - }); - - it("uses the full source descriptor instead of kind alone", () => { - const alpha = { kind: "variant-source", profile: "alpha" }; - const beta = { profile: "beta", kind: "variant-source" }; - - expect(projectConfigImportPreviewQueryKey("server", "/repo", alpha)).not.toEqual( - projectConfigImportPreviewQueryKey("server", "/repo", beta), - ); - }); - - it("serializes source descriptors deterministically", () => { - expect(stableProjectConfigImportSourceKey({ profile: "alpha", kind: "variant-source" })).toBe( - stableProjectConfigImportSourceKey({ kind: "variant-source", profile: "alpha" }), - ); - }); - - it("does not refetch a current availability preview when the sheet opens", async () => { - const calls: string[] = []; - const rpcSources: ProjectConfigImportSource[] = []; - const client = { - getProjectConfigImport: async (input: { - source: ProjectConfigImportSource; - }): Promise => { - calls.push("preview"); - rpcSources.push(input.source); - return { - ok: true, - requestId: "preview-1", - repoRoot: "/repo", - source: protocolSource, - status: "available", - sourceRevision: "source-1", - paseoRevision: null, - inputs: [], - items: [], - preview: {}, - }; - }, - }; - const queryClient = new QueryClient(); - const input = projectConfigImportPreviewQueryInput({ - client, - serverId: "server", - repoRoot: "/repo", - source: { ...protocolSource, profile: "alpha" }, - protocolSource, - enabled: true, - }); - - const options = fetchQueryOptions(input); - await queryClient.fetchQuery(options); - const observer = new QueryObserver(queryClient, queryClient.defaultQueryOptions(options)); - const unsubscribe = observer.subscribe(() => {}); - observer.getOptimisticResult(queryClient.defaultQueryOptions(options)); - unsubscribe(); - - expect(calls).toEqual(["preview"]); - expect(rpcSources).toEqual([protocolSource]); - expect(input.queryKey).toEqual( - projectConfigImportPreviewQueryKey("server", "/repo", { - ...protocolSource, - profile: "alpha", - }), - ); - }); - - it("refetches a stale availability preview when the settings page remounts", () => { - const calls: string[] = []; - const client = { - getProjectConfigImport: async (): Promise< - ProjectConfigImportPreview & { ok: true; requestId: string } - > => { - calls.push("preview"); - return { - ok: true, - requestId: "preview-current", - repoRoot: "/repo", - source: protocolSource, - status: "available", - sourceRevision: "source-current", - paseoRevision: null, - inputs: [], - items: [], - preview: {}, - }; - }, - }; - const input = projectConfigImportPreviewQueryInput({ - client, - serverId: "server", - repoRoot: "/repo", - source: fakeSource, - protocolSource, - enabled: true, - }); - const queryClient = new QueryClient(); - queryClient.setQueryData( - input.queryKey, - { - ok: true, - requestId: "preview-stale", - repoRoot: "/repo", - source: protocolSource, - status: "not_found", - sourceRevision: null, - paseoRevision: null, - inputs: [], - items: [], - preview: null, - }, - { updatedAt: Date.now() - 6_000 }, - ); - const options = fetchQueryOptions(input); - const observer = new QueryObserver(queryClient, queryClient.defaultQueryOptions(options)); - - const unsubscribe = observer.subscribe(() => {}); - - expect(calls).toEqual(["preview"]); - unsubscribe(); - }); - - it("keeps advertised identity separate from the protocol source after opening", async () => { - const sameKindRegistry = createProjectConfigImportSourceRegistry([ - { - kind: protocolSource.kind, - source: protocolSource, - displayName: "Protocol Source", - routeValue: "protocol-source", - }, - ]); - const [alphaRegistration, betaRegistration] = sameKindRegistry.advertised([ - { kind: protocolSource.kind, profile: "alpha" }, - { kind: protocolSource.kind, profile: "beta" }, - ]); - const alphaIntent = alphaRegistration - ? createProjectConfigImportIntentFromRegistration({ - serverId: "server", - registration: alphaRegistration, - intentId: "alpha", - }) - : null; - const betaIntent = betaRegistration - ? createProjectConfigImportIntentFromRegistration({ - serverId: "server", - registration: betaRegistration, - intentId: "beta", - }) - : null; - const calls: ProjectConfigImportSource[] = []; - const client = { - getProjectConfigImport: async (input: { - source: ProjectConfigImportSource; - }): Promise => { - calls.push(input.source); - return { - ok: true, - requestId: `preview-${calls.length}`, - repoRoot: "/repo", - source: input.source, - status: "available", - sourceRevision: `source-${calls.length}`, - paseoRevision: null, - inputs: [], - items: [], - preview: {}, - }; - }, - }; - const queryClient = new QueryClient(); - - expect(alphaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "alpha" }); - expect(betaIntent?.source).toEqual({ kind: protocolSource.kind, profile: "beta" }); - expect(alphaIntent?.protocolSource).toEqual(protocolSource); - expect(betaIntent?.protocolSource).toEqual(protocolSource); - - const alphaInput = projectConfigImportPreviewQueryInput({ - client, - serverId: "server", - repoRoot: "/repo", - source: alphaIntent?.source ?? null, - protocolSource: alphaIntent?.protocolSource ?? null, - enabled: true, - }); - const betaInput = projectConfigImportPreviewQueryInput({ - client, - serverId: "server", - repoRoot: "/repo", - source: betaIntent?.source ?? null, - protocolSource: betaIntent?.protocolSource ?? null, - enabled: true, - }); - - expect(alphaInput.queryKey).not.toEqual(betaInput.queryKey); - await queryClient.fetchQuery(fetchQueryOptions(alphaInput)); - await queryClient.fetchQuery(fetchQueryOptions(betaInput)); - expect(calls).toEqual([protocolSource, protocolSource]); - }); -}); - -describe("project config import availability", () => { - it("waits for advertised source previews before reporting no imports", () => { - expect(projectConfigImportAvailabilityStatus({ availableCount: 0, isLoading: true })).toBe( - "loading", - ); - expect(projectConfigImportAvailabilityStatus({ availableCount: 0, isLoading: false })).toBe( - "none", - ); - }); - - it("keeps invalid advertised sources openable", () => { - expect( - projectConfigImportPreviewIsOpenable({ - ok: false, - error: { code: "invalid_source_config" }, - }), - ).toBe(true); - expect( - projectConfigImportPreviewIsOpenable({ - ok: false, - error: { code: "source_config_not_found" }, - }), - ).toBe(false); - }); - - it("keeps warning-only advertised sources openable", () => { - expect( - projectConfigImportPreviewIsOpenable({ - ok: true, - status: "nothing_to_import", - items: [ - { key: "environment_variables", label: "Environment variables", outcome: "unsupported" }, - ], - }), - ).toBe(true); - expect( - projectConfigImportPreviewIsOpenable({ - ok: true, - status: "nothing_to_import", - items: [], - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/project-config-import/project-config-import-section.tsx b/packages/app/src/project-config-import/project-config-import-section.tsx deleted file mode 100644 index 360e3c30b..000000000 --- a/packages/app/src/project-config-import/project-config-import-section.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Pressable, Text, View } from "react-native"; -import { useCallback } from "react"; -import { StyleSheet } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { SettingsSection } from "@/screens/settings/settings-section"; -import { settingsStyles } from "@/styles/settings"; -import { stableProjectConfigImportSourceKey } from "./preview-cache"; -import type { ProjectConfigImportIntent } from "./route"; -import { ProjectConfigImportSheet } from "./project-config-import-sheet"; -import type { ProjectConfigImportSourceRegistration } from "./sources"; -import { getProjectConfigImportViewModule } from "./sources/view"; -import { useProjectConfigImportModel } from "./use-project-config-import-model"; - -export function ProjectConfigImportSection(input: { - client: DaemonClient; - serverId: string; - repoRoot: string; - routeIntent: ProjectConfigImportIntent | null; - onRouteIntentConsumed?: () => void; - projectConfigLoaded: boolean; - projectConfigQueryKey: readonly [string, string, string]; - hasUnsavedChanges: boolean; -}) { - const model = useProjectConfigImportModel(input); - - return ( - <> - {model.sources.map((source) => ( - - ))} - {model.state ? ( - - ) : null} - - ); -} - -function ProjectConfigImportRow({ - source, - activeSourceKind, - isAvailable, - onOpen, -}: { - source: ProjectConfigImportSourceRegistration; - activeSourceKind: string | null; - isAvailable: boolean; - onOpen: (source: ProjectConfigImportSourceRegistration) => void; -}) { - const { t } = useTranslation(); - const shouldShow = isAvailable || activeSourceKind === source.kind; - const handleOpen = useCallback(() => { - onOpen(source); - }, [onOpen, source]); - - if (!shouldShow) { - return null; - } - - const SourceIcon = getProjectConfigImportViewModule(source.source).Icon; - - const rowTitle = t("settings.project.import.rowTitle", { source: source.module.displayName }); - return ( - - - - - - {rowTitle} - - {t("settings.project.import.rowDescription", { source: source.module.displayName })} - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - importRow: { - ...settingsStyles.row, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - importText: { - flex: 1, - minWidth: 0, - }, - iconColor: { - color: theme.colors.foregroundMuted, - }, -})); diff --git a/packages/app/src/project-config-import/project-config-import-sheet.tsx b/packages/app/src/project-config-import/project-config-import-sheet.tsx deleted file mode 100644 index 04733e7d2..000000000 --- a/packages/app/src/project-config-import/project-config-import-sheet.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { Text, View } from "react-native"; -import { useMemo } from "react"; -import { StyleSheet } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import type { - ProjectConfigImportItem, - ProjectConfigImportPreview, - ProjectConfigRpcError, -} from "@getpaseo/protocol/messages"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; -import { Alert } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { settingsStyles } from "@/styles/settings"; -import type { ProjectConfigImportIntent } from "./route"; - -export type ProjectConfigImportVisibleError = - | ProjectConfigRpcError - | { code: "capability_missing" } - | { code: "unsaved_changes" } - | { code: "transport"; message: string }; - -export type ProjectConfigImportState = - | { status: "loading"; intent: ProjectConfigImportIntent; preview: null; error: null } - | { - status: "ready" | "applying"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview; - error: null; - } - | { - status: "error"; - intent: ProjectConfigImportIntent; - preview: ProjectConfigImportPreview | null; - error: ProjectConfigImportVisibleError; - retryAction: "refresh" | "apply"; - }; - -interface ProjectConfigImportSheetProps { - visible: boolean; - state: ProjectConfigImportState; - sourceName: string; - onClose: () => void; - onRefresh: () => void; - onApply: () => void; -} - -export function ProjectConfigImportSheet({ - visible, - state, - sourceName, - onClose, - onRefresh, - onApply, -}: ProjectConfigImportSheetProps) { - const { t } = useTranslation(); - const header = useMemo( - () => ({ title: t("settings.project.import.sheetTitle", { source: sourceName }) }), - [sourceName, t], - ); - const preview = state.preview; - const visibleError = state.status === "error" ? state.error : null; - const retryAction = state.status === "error" ? state.retryAction : "refresh"; - const canImport = - state.status === "ready" && - state.preview.status === "available" && - Boolean(state.preview.preview); - const isLoading = state.status === "loading"; - const isApplying = state.status === "applying"; - - return ( - - - {isLoading ? ( - - - - ) : null} - - {visibleError ? ( - - - - - ) : null} - - {preview ? : null} - - - - - - - - ); -} - -function ImportErrorRetryButton(input: { - error: ProjectConfigImportVisibleError; - retryAction: "refresh" | "apply"; - onRefresh: () => void; - onApply: () => void; -}) { - const { t } = useTranslation(); - if (input.error.code === "capability_missing" || input.error.code === "unsaved_changes") { - return null; - } - const needsRefresh = - input.error.code === "stale_source_config" || - input.error.code === "stale_project_config" || - input.error.code === "nothing_to_import"; - if (needsRefresh) { - return ( - - ); - } - return ( - - ); -} - -function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) { - const { t } = useTranslation(); - const sections = [ - { - title: t("settings.project.import.sources"), - items: preview.inputs.map((input) => ({ - key: `${input.role}:${input.relativePath}`, - label: input.relativePath, - outcome: "import" as const, - detail: input.role, - })), - }, - { - title: t("settings.project.import.willImport"), - items: preview.items.filter((item) => item.outcome === "import"), - }, - { - title: t("settings.project.import.needsAttention"), - items: preview.items.filter( - (item) => item.outcome === "rewrite" || item.outcome === "collision", - ), - }, - { - title: t("settings.project.import.notSupported"), - items: preview.items.filter((item) => item.outcome === "unsupported"), - }, - ]; - return ( - - {sections.map((section) => ( - - ))} - - ); -} - -function PreviewItemGroup({ title, items }: { title: string; items: ProjectConfigImportItem[] }) { - if (items.length === 0) { - return null; - } - return ( - - {title} - - {items.map((item, index) => ( - - {item.label} - {item.detail ? {item.detail} : null} - - ))} - - - ); -} - -function rowStyle(index: number) { - return index === 0 ? settingsStyles.row : styles.rowWithBorder; -} - -function projectConfigImportErrorText( - error: ProjectConfigImportVisibleError, - t: ReturnType["t"], - sourceName: string, -): string { - switch (error.code) { - case "transport": - return error.message; - case "capability_missing": - return t("settings.project.import.errors.capabilityMissing"); - case "unsaved_changes": - return t("settings.project.import.errors.unsavedChanges"); - case "source_config_not_found": - return t("settings.project.import.errors.notFound", { source: sourceName }); - case "invalid_source_config": - return t("settings.project.import.errors.invalid", { path: error.relativePath }); - case "stale_source_config": - return t("settings.project.import.errors.staleSource", { source: sourceName }); - case "stale_project_config": - return t("settings.project.import.errors.staleProject"); - case "nothing_to_import": - return t("settings.project.import.errors.nothing"); - case "write_failed": - return t("settings.project.writeFailures.failedDescription"); - case "invalid_project_config": - return t("settings.project.readFailures.invalidDescription"); - case "project_not_found": - return t("settings.project.readFailures.missingSingleHost"); - } -} - -const styles = StyleSheet.create((theme) => ({ - content: { - gap: theme.spacing[4], - }, - loading: { - minHeight: 120, - alignItems: "center", - justifyContent: "center", - }, - preview: { - gap: theme.spacing[4], - }, - group: { - gap: theme.spacing[2], - }, - groupTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - rowWithBorder: { - ...settingsStyles.row, - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - commandText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: theme.fontFamily.mono, - }, - footer: { - flexDirection: "row", - justifyContent: "flex-end", - gap: theme.spacing[2], - }, - spinnerColor: { - color: theme.colors.foregroundMuted, - }, -})); diff --git a/packages/app/src/project-config-import/retry.ts b/packages/app/src/project-config-import/retry.ts deleted file mode 100644 index cc114a15f..000000000 --- a/packages/app/src/project-config-import/retry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProjectConfigImportVisibleError } from "./project-config-import-sheet"; - -export type ProjectConfigImportRetryAction = "refresh" | "apply"; - -export function projectConfigImportApplyFailureRetryAction( - error: ProjectConfigImportVisibleError, -): ProjectConfigImportRetryAction { - return error.code === "stale_source_config" || - error.code === "stale_project_config" || - error.code === "nothing_to_import" || - error.code === "source_config_not_found" || - error.code === "invalid_source_config" - ? "refresh" - : "apply"; -} diff --git a/packages/app/src/project-config-import/route.ts b/packages/app/src/project-config-import/route.ts deleted file mode 100644 index f81c7e46b..000000000 --- a/packages/app/src/project-config-import/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; -import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages"; -import type { - ProjectConfigImportSourceDescriptor, - ProjectConfigImportSourceRegistry, -} from "./sources"; - -export interface ProjectConfigImportIntent< - TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor, - TProtocolSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSource, -> { - serverId: string; - source: TSource; - protocolSource: TProtocolSource; - intentId: string; -} - -export function parseProjectConfigImportIntent( - input: { - importSource?: string | string[]; - importServerId?: string | string[]; - importIntentId?: string | string[]; - }, - registry: ProjectConfigImportSourceRegistry, -): ProjectConfigImportIntent | null; - -export function parseProjectConfigImportIntent( - input: { - importSource?: string | string[]; - importServerId?: string | string[]; - importIntentId?: string | string[]; - }, - registry: ProjectConfigImportSourceRegistry, - parseSource: (source: ProjectConfigImportSourceDescriptor) => TSource | null, -): ProjectConfigImportIntent | null; - -export function parseProjectConfigImportIntent( - input: { - importSource?: string | string[]; - importServerId?: string | string[]; - importIntentId?: string | string[]; - }, - registry: ProjectConfigImportSourceRegistry, - parseSource?: ( - source: ProjectConfigImportSourceDescriptor, - ) => ProjectConfigImportSourceDescriptor | null, -): ProjectConfigImportIntent< - ProjectConfigImportSourceDescriptor, - ProjectConfigImportSourceDescriptor -> | null { - const source = first(input.importSource); - const serverId = first(input.importServerId); - const intentId = first(input.importIntentId); - const routeSource = source ? registry.fromRouteValue(source)?.source : null; - const parsedSource = routeSource ? (parseSource ?? parseProtocolSource)(routeSource) : null; - return parsedSource && routeSource && serverId && intentId - ? { serverId, source: routeSource, protocolSource: parsedSource, intentId } - : null; -} - -export function createProjectConfigImportIntentFromRegistration(input: { - serverId: string; - registration: { - source: ProjectConfigImportSourceDescriptor; - protocolSource: ProjectConfigImportSource | null; - }; - intentId: string; -}): ProjectConfigImportIntent | null { - return input.registration.protocolSource - ? { - serverId: input.serverId, - source: input.registration.source, - protocolSource: input.registration.protocolSource, - intentId: input.intentId, - } - : null; -} - -export function stripProjectConfigImportSearchParams(route: string): string { - const hashIndex = route.indexOf("#"); - const hash = hashIndex >= 0 ? route.slice(hashIndex) : ""; - const routeWithoutHash = hashIndex >= 0 ? route.slice(0, hashIndex) : route; - const searchIndex = routeWithoutHash.indexOf("?"); - if (searchIndex < 0) { - return route; - } - const pathname = routeWithoutHash.slice(0, searchIndex); - const params = new URLSearchParams(routeWithoutHash.slice(searchIndex + 1)); - params.delete("importSource"); - params.delete("importServerId"); - params.delete("importIntentId"); - const search = params.toString(); - return `${pathname}${search ? `?${search}` : ""}${hash}`; -} - -function parseProtocolSource( - source: ProjectConfigImportSourceDescriptor, -): ProjectConfigImportSource | null { - const parsed = ProjectConfigImportSourceSchema.safeParse(source); - return parsed.success ? parsed.data : null; -} - -function first(value: string | string[] | undefined): string | null { - const raw = Array.isArray(value) ? value[0] : value; - return typeof raw === "string" && raw.trim() ? raw.trim() : null; -} diff --git a/packages/app/src/project-config-import/sources/conductor.ts b/packages/app/src/project-config-import/sources/conductor.ts deleted file mode 100644 index 437cdbfec..000000000 --- a/packages/app/src/project-config-import/sources/conductor.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ProjectConfigImportLogicModule } from "."; - -export const conductorProjectConfigImportSource = { - kind: "conductor", - source: { kind: "conductor" }, - displayName: "Conductor", - routeValue: "conductor", -} satisfies ProjectConfigImportLogicModule<{ kind: "conductor" }>; diff --git a/packages/app/src/project-config-import/sources/conductor.view-registration.ts b/packages/app/src/project-config-import/sources/conductor.view-registration.ts deleted file mode 100644 index 78a3b5868..000000000 --- a/packages/app/src/project-config-import/sources/conductor.view-registration.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createElement } from "react"; -import type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry"; - -function ConductorImportIcon(props: ProjectConfigImportIconProps) { - const { ConductorIcon } = require("./conductor.view") as typeof import("./conductor.view"); - return createElement(ConductorIcon, props); -} - -export const conductorProjectConfigImportView = { - kind: "conductor", - Icon: ConductorImportIcon, -} satisfies ProjectConfigImportViewModule; diff --git a/packages/app/src/project-config-import/sources/conductor.view.tsx b/packages/app/src/project-config-import/sources/conductor.view.tsx deleted file mode 100644 index bb75b71dd..000000000 --- a/packages/app/src/project-config-import/sources/conductor.view.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Svg, { Path } from "react-native-svg"; -import type { ProjectConfigImportViewModule } from "./view-registry"; - -const PATHS = [ - "M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z", - "M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z", - "M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z", - "M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z", - "M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z", - "M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z", - "M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z", - "M37.0967 173.493V147.045H58.9707V173.493H37.0967Z", - "M55.3955 173.493V147.045H77.2695V173.493H55.3955Z", - "M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z", - "M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z", - "M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z", - "M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z", - "M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z", - "M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z", - "M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z", - "M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z", - "M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z", - "M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z", -] as const; - -export function ConductorIcon({ size = 18, color = "#282423" }: { size?: number; color?: string }) { - return ( - - {PATHS.map((path) => ( - - ))} - - ); -} - -export const conductorProjectConfigImportView = { - kind: "conductor", - Icon: ConductorIcon, -} satisfies ProjectConfigImportViewModule; diff --git a/packages/app/src/project-config-import/sources/index.test.ts b/packages/app/src/project-config-import/sources/index.test.ts deleted file mode 100644 index 615963eb4..000000000 --- a/packages/app/src/project-config-import/sources/index.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createProjectConfigImportSourceRegistry, - projectConfigImportSourceRegistry, - type ProjectConfigImportLogicModule, -} from "."; -import { projectConfigImportViewRegistry } from "./view"; -import { createProjectConfigImportViewRegistry } from "./view-registry"; - -const fakeSource = { - kind: "fake-source", - source: { kind: "fake-source", profile: "alpha" }, - displayName: "Fake Source", - routeValue: "fake", -} satisfies ProjectConfigImportLogicModule<{ kind: "fake-source"; profile: "alpha" }>; - -describe("project config import app registry", () => { - it("filters future advertised sources through registered logic modules", () => { - const registry = createProjectConfigImportSourceRegistry([fakeSource]); - - expect( - registry.advertised([ - { kind: "future-source", capability: "unknown" }, - { kind: "fake-source", profile: "alpha" }, - ]), - ).toEqual([ - { - kind: "fake-source", - source: { kind: "fake-source", profile: "alpha" }, - protocolSource: null, - module: fakeSource, - }, - ]); - }); - - it("preserves advertised descriptors before normalizing RPC sources", () => { - const registry = createProjectConfigImportSourceRegistry([ - { - kind: "conductor", - source: { kind: "conductor" }, - displayName: "Known Source", - routeValue: "known", - }, - ]); - - expect(registry.advertised([{ kind: "conductor", profile: "alpha" }])).toMatchObject([ - { - kind: "conductor", - source: { kind: "conductor", profile: "alpha" }, - protocolSource: { kind: "conductor" }, - module: { displayName: "Known Source" }, - }, - ]); - }); - - it("rejects duplicate logic kinds and route values", () => { - expect(() => createProjectConfigImportSourceRegistry([fakeSource, fakeSource])).toThrow( - "Duplicate project config import source: fake-source", - ); - expect(() => - createProjectConfigImportSourceRegistry([ - fakeSource, - { ...fakeSource, kind: "other-source" }, - ]), - ).toThrow("Duplicate project config import route value: fake"); - }); - - it("keeps production protocol sources registered", () => { - expect(() => projectConfigImportSourceRegistry.assertProtocolCoverage()).not.toThrow(); - }); -}); - -describe("project config import view registry", () => { - const FakeIcon = () => null; - - it("rejects duplicate views and missing view modules", () => { - expect(() => - createProjectConfigImportViewRegistry([ - { kind: "fake-source", Icon: FakeIcon }, - { kind: "fake-source", Icon: FakeIcon }, - ]), - ).toThrow("Duplicate project config import view: fake-source"); - - const registry = createProjectConfigImportViewRegistry([]); - expect(() => registry.get(fakeSource.source)).toThrow( - "Missing project config import view: fake-source", - ); - }); - - it("keeps production logic and view registries in parity", () => { - const kinds = projectConfigImportSourceRegistry.all().map((source) => source.kind); - expect(projectConfigImportViewRegistry.kinds()).toEqual(kinds); - }); -}); diff --git a/packages/app/src/project-config-import/sources/index.ts b/packages/app/src/project-config-import/sources/index.ts deleted file mode 100644 index fc891993e..000000000 --- a/packages/app/src/project-config-import/sources/index.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages"; -import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; -import { conductorProjectConfigImportSource } from "./conductor"; - -export interface ProjectConfigImportSourceDescriptor { - kind: string; - [key: string]: unknown; -} - -export interface ProjectConfigImportLogicModule< - TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor, -> { - readonly kind: TSource["kind"]; - readonly source: TSource; - readonly displayName: string; - readonly routeValue: string; -} - -export interface ProjectConfigImportSourceRegistration< - TSource extends ProjectConfigImportSourceDescriptor = ProjectConfigImportSourceDescriptor, -> { - readonly kind: string; - readonly source: TSource; - readonly protocolSource: ProjectConfigImportSource | null; - readonly module: ProjectConfigImportLogicModule; -} - -const PROJECT_CONFIG_IMPORT_LOGIC_MODULES = [conductorProjectConfigImportSource]; - -export const projectConfigImportSourceRegistry = createProjectConfigImportSourceRegistry( - PROJECT_CONFIG_IMPORT_LOGIC_MODULES, -); - -export type ProjectConfigImportSourceRegistry = ReturnType< - typeof createProjectConfigImportSourceRegistry ->; - -export function createProjectConfigImportSourceRegistry< - const TModules extends readonly ProjectConfigImportLogicModule[], ->(modules: TModules) { - const byKind = new Map(); - const byRouteValue = new Map(); - - for (const module of modules) { - if (byKind.has(module.kind)) { - throw new Error(`Duplicate project config import source: ${module.kind}`); - } - if (byRouteValue.has(module.routeValue)) { - throw new Error(`Duplicate project config import route value: ${module.routeValue}`); - } - byKind.set(module.kind, module); - byRouteValue.set(module.routeValue, module); - } - - return { - all(): ProjectConfigImportLogicModule[] { - return Array.from(byKind.values()); - }, - get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportLogicModule | null { - return byKind.get(source.kind) ?? null; - }, - fromRouteValue(routeValue: string): ProjectConfigImportLogicModule | null { - return byRouteValue.get(routeValue) ?? null; - }, - routeValue(source: ProjectConfigImportSourceDescriptor): string | null { - return byKind.get(source.kind)?.routeValue ?? null; - }, - assertProtocolCoverage(): void { - const missing = readProtocolSourceKinds().filter((kind) => !byKind.has(kind)); - if (missing.length > 0) { - throw new Error(`Missing project config import sources: ${missing.join(", ")}`); - } - }, - advertised( - sources: readonly ProjectConfigImportSourceDescriptor[] | null | undefined, - ): ProjectConfigImportSourceRegistration[] { - if (!sources) { - return []; - } - return sources - .map((source) => { - const module = byKind.get(source.kind); - return module - ? { - kind: source.kind, - source, - protocolSource: toProjectConfigImportProtocolSource(source), - module, - } - : null; - }) - .filter((source): source is ProjectConfigImportSourceRegistration => source !== null); - }, - }; -} - -export function toProjectConfigImportProtocolSource( - source: ProjectConfigImportSourceDescriptor, -): ProjectConfigImportSource | null { - const parsed = ProjectConfigImportSourceSchema.safeParse(source); - return parsed.success ? parsed.data : null; -} - -function readProtocolSourceKinds(): string[] { - return ProjectConfigImportSourceSchema.options.map((option) => option.shape.kind.value); -} diff --git a/packages/app/src/project-config-import/sources/view-registry.ts b/packages/app/src/project-config-import/sources/view-registry.ts deleted file mode 100644 index 34dd570cd..000000000 --- a/packages/app/src/project-config-import/sources/view-registry.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentType } from "react"; -import type { ProjectConfigImportSourceDescriptor } from "."; - -export interface ProjectConfigImportIconProps { - size?: number; - color?: string; -} - -export interface ProjectConfigImportViewModule { - readonly kind: string; - readonly Icon: ComponentType; -} - -export function createProjectConfigImportViewRegistry( - modules: readonly ProjectConfigImportViewModule[], -) { - const byKind = new Map(); - for (const module of modules) { - if (byKind.has(module.kind)) { - throw new Error(`Duplicate project config import view: ${module.kind}`); - } - byKind.set(module.kind, module); - } - - return { - kinds(): string[] { - return Array.from(byKind.keys()); - }, - get(source: ProjectConfigImportSourceDescriptor): ProjectConfigImportViewModule { - const module = byKind.get(source.kind); - if (!module) { - throw new Error(`Missing project config import view: ${source.kind}`); - } - return module; - }, - }; -} diff --git a/packages/app/src/project-config-import/sources/view.ts b/packages/app/src/project-config-import/sources/view.ts deleted file mode 100644 index 873573d9d..000000000 --- a/packages/app/src/project-config-import/sources/view.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProjectConfigImportSourceDescriptor } from "."; -import { conductorProjectConfigImportView } from "./conductor.view-registration"; -import { createProjectConfigImportViewRegistry } from "./view-registry"; -import type { ProjectConfigImportViewModule } from "./view-registry"; -export type { ProjectConfigImportIconProps, ProjectConfigImportViewModule } from "./view-registry"; - -const PROJECT_CONFIG_IMPORT_VIEW_MODULES = [conductorProjectConfigImportView]; - -export const projectConfigImportViewRegistry = createProjectConfigImportViewRegistry( - PROJECT_CONFIG_IMPORT_VIEW_MODULES, -); - -export function getProjectConfigImportViewModule( - source: ProjectConfigImportSourceDescriptor, -): ProjectConfigImportViewModule { - return projectConfigImportViewRegistry.get(source); -} diff --git a/packages/app/src/project-config-import/use-project-config-import-model.ts b/packages/app/src/project-config-import/use-project-config-import-model.ts deleted file mode 100644 index b89a542b4..000000000 --- a/packages/app/src/project-config-import/use-project-config-import-model.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { - type ProjectConfigImportAdvertisedSource, - type ProjectConfigImportSource, - type ProjectConfigRpcError, -} from "@getpaseo/protocol/messages"; -import { useToast } from "@/contexts/toast-context"; -import { useFetchQueries, useFetchQuery } from "@/data/query"; -import { useSessionStore } from "@/stores/session-store"; -import { - projectConfigImportPreviewQueryKey, - projectConfigImportPreviewQueryInput, - stableProjectConfigImportSourceKey, - type ProjectConfigImportPreviewResult, -} from "./preview-cache"; -import type { - ProjectConfigImportState, - ProjectConfigImportVisibleError, -} from "./project-config-import-sheet"; -import { - createProjectConfigImportIntentFromRegistration, - type ProjectConfigImportIntent, -} from "./route"; -import { - type ProjectConfigImportSourceRegistration, - projectConfigImportSourceRegistry, - type ProjectConfigImportSourceRegistry, -} from "./sources"; -import { - projectConfigImportApplyFailureRetryAction, - type ProjectConfigImportRetryAction, -} from "./retry"; -import { - projectConfigImportAvailabilityStatus, - projectConfigImportPreviewIsOpenable, -} from "./availability"; - -const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = []; -type ProjectConfigImportPreviewSuccess = Extract; - -export function useProjectConfigImportModel(input: { - routeIntent: ProjectConfigImportIntent | null; - repoRoot: string; - serverId: string; - client: DaemonClient | null; - projectConfigLoaded: boolean; - projectConfigQueryKey: readonly [string, string, string]; - hasUnsavedChanges: boolean; - registry?: ProjectConfigImportSourceRegistry; - onRouteIntentConsumed?: () => void; -}) { - const registry = input.registry ?? projectConfigImportSourceRegistry; - const onRouteIntentConsumed = input.onRouteIntentConsumed; - const sources = useAdvertisedProjectConfigImportSources(input.serverId, registry); - const [intent, setIntent] = useState(null); - const consumedRouteIntentKeyRef = useRef(null); - const acknowledgedRouteIntentKeyRef = useRef(null); - const [applyError, setApplyError] = useState(null); - const [retryAction, setRetryAction] = useState("apply"); - const activeSource = intent ? registry.get(intent.source) : null; - const routeIntentCapabilityMissing = isRouteIntentCapabilityMissing({ - intent, - routeIntent: input.routeIntent, - sources, - }); - const activePreview = useProjectConfigImportPreviewQuery({ - client: input.client, - serverId: input.serverId, - repoRoot: input.repoRoot, - source: intent?.source ?? null, - protocolSource: intent?.protocolSource ?? null, - enabled: Boolean( - intent && - input.projectConfigLoaded && - !routeIntentCapabilityMissing && - !input.hasUnsavedChanges, - ), - }); - const preview = activePreview.data?.ok ? activePreview.data : null; - const queryClient = useQueryClient(); - const toast = useToast(); - const { t } = useTranslation(); - - useEffect(() => { - const routeIntentKey = input.routeIntent - ? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}` - : null; - if ( - input.routeIntent?.serverId === input.serverId && - routeIntentKey && - consumedRouteIntentKeyRef.current !== routeIntentKey - ) { - consumedRouteIntentKeyRef.current = routeIntentKey; - setIntent(input.routeIntent); - } - }, [input.routeIntent, input.serverId]); - - useEffect(() => { - const routeIntentKey = input.routeIntent - ? `${input.routeIntent.serverId}:${stableProjectConfigImportSourceKey(input.routeIntent.source)}:${input.routeIntent.intentId}` - : null; - const openIntentKey = intent - ? `${intent.serverId}:${stableProjectConfigImportSourceKey(intent.source)}:${intent.intentId}` - : null; - if ( - routeIntentKey && - routeIntentKey === openIntentKey && - acknowledgedRouteIntentKeyRef.current !== routeIntentKey - ) { - acknowledgedRouteIntentKeyRef.current = routeIntentKey; - onRouteIntentConsumed?.(); - } - }, [input.routeIntent, intent, onRouteIntentConsumed]); - - useEffect(() => { - setApplyError(null); - }, [ - intent?.intentId, - preview?.sourceRevision, - preview?.paseoRevision?.mtimeMs, - preview?.paseoRevision?.size, - ]); - - const applyMutation = useMutation({ - mutationFn: async () => { - if (!input.client || !intent || !preview?.sourceRevision) { - throw new Error("Import preview is not available"); - } - return input.client.applyProjectConfigImport({ - repoRoot: input.repoRoot, - source: intent.protocolSource, - expectedSourceRevision: preview.sourceRevision, - expectedPaseoRevision: preview.paseoRevision, - }); - }, - onSuccess: (result) => { - if (!result.ok) { - setApplyError(result.error); - setRetryAction(projectConfigImportApplyFailureRetryAction(result.error)); - return; - } - queryClient.setQueryData(input.projectConfigQueryKey, { - ok: true, - config: result.config, - revision: result.revision, - requestId: "import-cache", - repoRoot: input.repoRoot, - }); - queryClient.invalidateQueries({ queryKey: ["projects"] }); - void queryClient.invalidateQueries({ - queryKey: projectConfigImportPreviewQueryKey( - input.serverId, - input.repoRoot, - intent?.source ?? result.source, - ), - exact: true, - }); - const appliedSource = registry.get(result.source); - toast.show( - t("settings.project.import.success", { - source: appliedSource?.displayName ?? result.source.kind, - }), - { variant: "success" }, - ); - setIntent(null); - }, - onError: (cause) => { - setApplyError( - normalizeProjectConfigImportError( - cause instanceof Error ? cause : new Error(String(cause)), - ), - ); - setRetryAction("apply"); - }, - }); - - const availability = useProjectConfigImportAvailability({ - client: input.client, - serverId: input.serverId, - repoRoot: input.repoRoot, - enabled: input.projectConfigLoaded, - registry, - }); - const state = useMemo(() => { - if (!intent) { - return null; - } - const error = projectConfigImportVisibleError({ - routeIntentCapabilityMissing, - hasUnsavedChanges: input.hasUnsavedChanges, - applyError, - preview, - requestError: - activePreview.data && !activePreview.data.ok - ? activePreview.data.error - : activePreview.error, - }); - if (error) { - return { - status: "error", - intent, - preview, - error, - retryAction: applyError ? retryAction : "refresh", - }; - } - if (preview && applyMutation.isPending) { - return { status: "applying", intent, preview, error: null }; - } - if (preview) { - return { status: "ready", intent, preview, error: null }; - } - return { status: "loading", intent, preview: null, error: null }; - }, [ - activePreview.data, - activePreview.error, - applyError, - applyMutation.isPending, - intent, - input.hasUnsavedChanges, - preview, - retryAction, - routeIntentCapabilityMissing, - ]); - - return { - sources, - intent, - state, - activeSourceName: activeSource?.displayName ?? null, - availability, - open: (source: ProjectConfigImportSourceRegistration) => { - const nextIntent = createProjectConfigImportIntentFromRegistration({ - serverId: input.serverId, - registration: source, - intentId: String(Date.now()), - }); - if (nextIntent) { - setIntent(nextIntent); - } - }, - close: () => setIntent(null), - refresh: () => { - void activePreview.refetch(); - }, - apply: () => { - if (!applyMutation.isPending && !input.hasUnsavedChanges) { - setApplyError(null); - applyMutation.mutate(); - } - }, - }; -} - -function projectConfigImportPreviewError( - preview: ProjectConfigImportPreviewSuccess | null, -): ProjectConfigRpcError | null { - if (preview?.status === "not_found") { - return { code: "source_config_not_found", source: preview.source }; - } - if (preview?.status === "nothing_to_import") { - return { code: "nothing_to_import" }; - } - return null; -} - -function projectConfigImportVisibleError(input: { - routeIntentCapabilityMissing: boolean; - hasUnsavedChanges: boolean; - applyError: ProjectConfigImportVisibleError | null; - preview: ProjectConfigImportPreviewSuccess | null; - requestError: ProjectConfigRpcError | Error | null; -}): ProjectConfigImportVisibleError | null { - if (input.routeIntentCapabilityMissing) { - return { code: "capability_missing" }; - } - if (input.hasUnsavedChanges) { - return { code: "unsaved_changes" }; - } - return ( - input.applyError ?? - projectConfigImportPreviewError(input.preview) ?? - normalizeProjectConfigImportError(input.requestError) - ); -} - -export function useProjectConfigImportAvailability(input: { - client: DaemonClient | null; - serverId: string | null | undefined; - repoRoot: string | null | undefined; - enabled: boolean; - registry?: ProjectConfigImportSourceRegistry; -}) { - const registry = input.registry ?? projectConfigImportSourceRegistry; - const serverId = input.serverId ?? ""; - const repoRoot = input.repoRoot ?? ""; - const sources = useAdvertisedProjectConfigImportSources(serverId, registry); - const previews = useProjectConfigImportPreviewQueries({ - client: input.client, - serverId, - repoRoot, - sources, - enabled: input.enabled, - }); - const openableSources = sources.filter((_, index) => - projectConfigImportPreviewIsOpenable(previews[index]?.data), - ); - const availableKinds = new Set(openableSources.map((source) => source.kind)); - const availableSourceKeys = new Set( - openableSources.map((source) => stableProjectConfigImportSourceKey(source.source)), - ); - const isLoading = previews.some((preview) => preview.isLoading || preview.isPending); - - return { - status: projectConfigImportAvailabilityStatus({ - availableCount: openableSources.length, - isLoading, - }), - source: openableSources.length === 1 ? openableSources[0] : null, - sources: openableSources, - availableKinds, - availableSourceKeys, - }; -} - -function isRouteIntentCapabilityMissing(input: { - intent: ProjectConfigImportIntent | null; - routeIntent: ProjectConfigImportIntent | null; - sources: ProjectConfigImportSourceRegistration[]; -}): boolean { - if (!input.intent || input.routeIntent?.intentId !== input.intent.intentId) { - return false; - } - const intentSourceKey = stableProjectConfigImportSourceKey(input.intent.source); - return !input.sources.some( - (source) => stableProjectConfigImportSourceKey(source.source) === intentSourceKey, - ); -} - -function useAdvertisedProjectConfigImportSources( - serverId: string | null | undefined, - registry: ProjectConfigImportSourceRegistry, -): ProjectConfigImportSourceRegistration[] { - const advertised = useSessionStore( - useCallback( - (state) => { - const id = serverId?.trim(); - return id - ? (state.sessions[id]?.serverInfo?.features?.projectConfigImportSources ?? - EMPTY_IMPORT_SOURCES) - : EMPTY_IMPORT_SOURCES; - }, - [serverId], - ), - ); - return useMemo(() => registry.advertised(advertised), [advertised, registry]); -} - -function useProjectConfigImportPreviewQueries(input: { - client: DaemonClient | null; - serverId: string; - repoRoot: string; - sources: readonly ProjectConfigImportSourceRegistration[]; - enabled: boolean; -}) { - return useFetchQueries( - input.sources.map((source) => - projectConfigImportPreviewQueryInput({ - client: input.client, - serverId: input.serverId, - repoRoot: input.repoRoot, - source: source.source, - protocolSource: source.protocolSource, - enabled: input.enabled, - }), - ), - ); -} - -function useProjectConfigImportPreviewQuery(input: { - client: DaemonClient | null; - serverId: string; - repoRoot: string; - source: ProjectConfigImportSourceRegistration["source"] | null; - protocolSource: ProjectConfigImportSource | null; - enabled: boolean; -}) { - return useFetchQuery(projectConfigImportPreviewQueryInput(input)); -} - -function normalizeProjectConfigImportError( - error: ProjectConfigRpcError | Error | null, -): ProjectConfigImportVisibleError | null { - if (!error) { - return null; - } - return error instanceof Error - ? { code: "transport", message: error.message || "The host did not respond." } - : error; -} diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 5c934ffac..8a9f5e8bd 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -26,9 +26,6 @@ import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; -import type { ProjectConfigImportIntent } from "@/project-config-import/route"; -import { ProjectConfigImportSection } from "@/project-config-import/project-config-import-section"; -import { projectConfigImportPreviewQueryRoot } from "@/project-config-import/preview-cache"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; @@ -41,7 +38,6 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { applyDraftToConfig, configToDraft, - hasProjectConfigDraftChanges, METADATA_PROMPT_KEYS, type LifecycleOriginalKind, type MetadataPromptKey, @@ -89,15 +85,9 @@ type ReadProjectConfigData = Awaited void; } -export default function ProjectSettingsScreen({ - projectKey, - importIntent = null, - onImportIntentConsumed, -}: ProjectSettingsScreenProps) { +export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) { const { projects } = useProjects(); const project = useMemo( () => projects.find((entry) => entry.projectKey === projectKey), @@ -106,22 +96,15 @@ export default function ProjectSettingsScreen({ const editableHosts = useMemo(() => filterEditableHosts(project), [project]); const [selectedServerId, setSelectedServerId] = useState( - () => - (importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId) - ? importIntent.serverId - : editableHosts[0]?.serverId) ?? "", + () => editableHosts[0]?.serverId ?? "", ); useEffect(() => { - if (importIntent && editableHosts.some((host) => host.serverId === importIntent.serverId)) { - setSelectedServerId(importIntent.serverId); - return; - } const stillValid = editableHosts.some((host) => host.serverId === selectedServerId); if (!stillValid) { setSelectedServerId(editableHosts[0]?.serverId ?? ""); } - }, [editableHosts, importIntent, selectedServerId]); + }, [editableHosts, selectedServerId]); const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); const isHostGone = @@ -144,8 +127,6 @@ export default function ProjectSettingsScreen({ onSelectHost={setSelectedServerId} client={client} isHostGone={isHostGone} - importIntent={importIntent} - onImportIntentConsumed={onImportIntentConsumed} /> ); } @@ -203,8 +184,6 @@ interface ProjectSettingsBodyProps { onSelectHost: (serverId: string) => void; client: DaemonClient; isHostGone: boolean; - importIntent: ProjectConfigImportIntent | null; - onImportIntentConsumed?: () => void; } function ProjectSettingsBody({ @@ -214,10 +193,7 @@ function ProjectSettingsBody({ onSelectHost, client, isHostGone, - importIntent, - onImportIntentConsumed, }: ProjectSettingsBodyProps) { - const queryClient = useQueryClient(); const queryKey = useMemo( () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, [selectedHost.serverId, selectedHost.repoRoot], @@ -244,14 +220,13 @@ function ProjectSettingsBody({ projects: projectIconTargets, }); const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; - const readState = resolveProjectConfigReadState(data); + const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; + const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; + const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; const handleReload = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: projectConfigImportPreviewQueryRoot(selectedHost.serverId, selectedHost.repoRoot), - }); void readQuery.refetch(); - }, [queryClient, readQuery, selectedHost.repoRoot, selectedHost.serverId]); + }, [readQuery]); const hasMultipleHosts = hosts.length > 1; @@ -273,40 +248,20 @@ function ProjectSettingsBody({ {renderContent({ readQuery, - loadedConfig: readState.loadedConfig, - loadedRevision: readState.loadedRevision, - readError: readState.readError, + loadedConfig, + loadedRevision, + readError, selectedHost, queryKey, client, onReload: handleReload, hasMultipleHosts, isHostGone, - importIntent, - onImportIntentConsumed, })} ); } -function resolveProjectConfigReadState(data: ReadProjectConfigData | undefined): { - loadedConfig: PaseoConfigRaw | null; - loadedRevision: PaseoConfigRevision | null; - readError: ProjectConfigRpcError | null; -} { - if (!data) { - return { loadedConfig: null, loadedRevision: null, readError: null }; - } - if (!data.ok) { - return { loadedConfig: null, loadedRevision: null, readError: data.error }; - } - return { - loadedConfig: data.config ?? {}, - loadedRevision: data.revision, - readError: null, - }; -} - interface RenderContentInput { readQuery: ReturnType>; loadedConfig: PaseoConfigRaw | null; @@ -318,8 +273,6 @@ interface RenderContentInput { onReload: () => void; hasMultipleHosts: boolean; isHostGone: boolean; - importIntent: ProjectConfigImportIntent | null; - onImportIntentConsumed?: () => void; } function renderContent({ @@ -333,8 +286,6 @@ function renderContent({ onReload, hasMultipleHosts, isHostGone, - importIntent, - onImportIntentConsumed, }: RenderContentInput) { if (readQuery.isLoading) { return ( @@ -384,13 +335,10 @@ function renderContent({ key={formKey} baseConfig={loadedConfig} revision={loadedRevision} - serverId={selectedHost.serverId} repoRoot={selectedHost.repoRoot} queryKey={queryKey} client={client} onReload={onReload} - importIntent={importIntent} - onImportIntentConsumed={onImportIntentConsumed} /> ); } @@ -472,25 +420,19 @@ function errorToDetail(error: unknown): string | null { interface ProjectConfigFormProps { baseConfig: PaseoConfigRaw; revision: PaseoConfigRevision | null; - serverId: string; repoRoot: string; queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; - importIntent: ProjectConfigImportIntent | null; - onImportIntentConsumed?: () => void; } function ProjectConfigForm({ baseConfig, revision, - serverId, repoRoot, queryKey, client, onReload, - importIntent, - onImportIntentConsumed, }: ProjectConfigFormProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -522,9 +464,6 @@ function ProjectConfigForm({ }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); - void queryClient.invalidateQueries({ - queryKey: projectConfigImportPreviewQueryRoot(serverId, repoRoot), - }); toast.show(t("settings.project.actions.saved"), { variant: "success" }); } else { setWriteError(result.error); @@ -690,10 +629,6 @@ function ProjectConfigForm({ const isStale = writeError?.code === "stale_project_config"; const isWriteFailed = writeError?.code === "write_failed"; const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts; - const hasUnsavedChanges = useMemo( - () => hasProjectConfigDraftChanges({ draft, base: baseConfig }), - [baseConfig, draft], - ); return ( @@ -702,17 +637,6 @@ function ProjectConfigForm({ info={t("settings.project.worktree.info")} testID="worktree-group" > - - void; - }; + | { kind: "project"; projectKey: string }; interface SidebarSectionItem { id: SettingsSectionSlug; @@ -1391,13 +1385,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ; } if (view.kind === "project") { - return ( - - ); + return ; } if (view.kind === "section") { switch (view.section) { diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 51335dfcd..fbc8115fc 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -9,7 +9,6 @@ import { resolveKnownHostRoute, buildSessionsRoute, buildSettingsAddHostRoute, - buildProjectSettingsImportRoute, buildProjectSettingsRoute, buildProjectsSettingsRoute, decodeFilePathFromPathSegment, @@ -209,19 +208,6 @@ describe("projects settings routes", () => { ); }); - it("buildProjectSettingsImportRoute includes the import source, host, and intent", () => { - expect( - buildProjectSettingsImportRoute({ - projectKey: "remote:github.com/acme/app", - source: "fake", - serverId: "server-1", - intentId: "intent 1", - }), - ).toBe( - "/settings/projects/remote%3Agithub.com%2Facme%2Fapp?importSource=fake&importServerId=server-1&importIntentId=intent+1", - ); - }); - it("project keys round-trip through decodeURIComponent", () => { const projectKey = "remote:github.com/acme/app"; const route = buildProjectSettingsRoute(projectKey); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 7f9c5cc5a..717bc8296 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -568,18 +568,3 @@ export function buildProjectSettingsRoute(projectKey: string) { } return `/settings/projects/${encodeSegment(normalized)}` as const; } - -export function buildProjectSettingsImportRoute(input: { - projectKey: string; - source: string; - serverId: string; - intentId: string; -}) { - const base = buildProjectSettingsRoute(input.projectKey); - const query = new URLSearchParams({ - importSource: input.source, - importServerId: input.serverId, - importIntentId: input.intentId, - }); - return `${base}?${query.toString()}` as const; -} diff --git a/packages/app/src/utils/project-config-form.test.ts b/packages/app/src/utils/project-config-form.test.ts index 3c1dce330..67ca9c427 100644 --- a/packages/app/src/utils/project-config-form.test.ts +++ b/packages/app/src/utils/project-config-form.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from "vitest"; import { PaseoConfigRawSchema } from "@getpaseo/protocol/paseo-config-schema"; import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; -import { - applyDraftToConfig, - configToDraft, - hasProjectConfigDraftChanges, - type ProjectConfigDraft, -} from "./project-config-form"; +import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form"; function emptyDraft(): ProjectConfigDraft { return { @@ -355,24 +350,3 @@ describe("applyDraftToConfig", () => { expect(Object.keys(scripts)).toEqual(["dev"]); }); }); - -describe("hasProjectConfigDraftChanges", () => { - it("distinguishes an untouched form from unsaved project edits", () => { - const base = { worktree: { setup: "npm ci" } }; - const untouched = configToDraft(base); - const edited = { ...untouched, setupText: "npm install" }; - - expect(hasProjectConfigDraftChanges({ draft: untouched, base })).toBe(false); - expect(hasProjectConfigDraftChanges({ draft: edited, base })).toBe(true); - }); - - it("treats empty config containers as an untouched canonical form", () => { - const base: PaseoConfigRaw = { - worktree: {}, - scripts: {}, - metadataGeneration: {}, - }; - - expect(hasProjectConfigDraftChanges({ draft: configToDraft(base), base })).toBe(false); - }); -}); diff --git a/packages/app/src/utils/project-config-form.ts b/packages/app/src/utils/project-config-form.ts index 68f979105..8e75ba0a4 100644 --- a/packages/app/src/utils/project-config-form.ts +++ b/packages/app/src/utils/project-config-form.ts @@ -4,7 +4,6 @@ import type { PaseoMetadataGenerationEntry, PaseoScriptEntryRaw, } from "@getpaseo/protocol/messages"; -import equal from "fast-deep-equal"; export type LifecycleOriginalKind = "string" | "array" | "missing"; @@ -245,11 +244,3 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw { } return result as PaseoConfigRaw; } - -export function hasProjectConfigDraftChanges(input: ApplyDraftInput): boolean { - const canonicalBase = applyDraftToConfig({ - draft: configToDraft(input.base), - base: input.base, - }); - return !equal(applyDraftToConfig(input), canonicalBase); -} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 697b1b030..79a989367 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,22 +1,21 @@ -import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { - buildDaemonWebSocketUrl, - buildRelayWebSocketUrl, - normalizeHostPort, - parseConnectionUri, - shouldUseTlsForDefaultHostedRelay, -} from "@getpaseo/protocol/daemon-endpoints"; -import { - parseConnectionOfferFromUrl, - type ConnectionOffer, -} from "@getpaseo/protocol/connection-offer"; -import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import path from "node:path"; -import { WebSocket } from "ws"; + connectToDaemon as connectNodeClient, + normalizeDaemonHost, + resolveDaemonPassword, + resolveDaemonTarget, + resolveDefaultDaemonHosts, +} from "@getpaseo/client/node"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; +export { + normalizeDaemonHost, + resolveDaemonPassword, + resolveDaemonTarget, + resolveDefaultDaemonHosts, +}; + export interface ConnectOptions { host?: string; timeout?: number; @@ -28,26 +27,14 @@ export interface DaemonConnectionCommandError { details: string; } -const DEFAULT_HOST = "localhost:6767"; -const DEFAULT_TIMEOUT = 15000; -const PID_FILENAME = "paseo.pid"; - -type DaemonTarget = - | { - type: "tcp"; - url: string; - } - | { - type: "ipc"; - url: string; - socketPath: string; - }; - -/** - * Get the daemon host from environment or options - */ export function getDaemonHost(options?: ConnectOptions): string { - return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; + return ( + options?.host ?? process.env.PASEO_HOST ?? resolveDefaultDaemonHosts()[0] ?? "localhost:6767" + ); +} + +export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string { + return resolveDefaultDaemonHosts(env)[0] ?? "localhost:6767"; } export function buildDaemonConnectionCommandError(options: { @@ -63,324 +50,16 @@ export function buildDaemonConnectionCommandError(options: { }; } -export function normalizeDaemonHost(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("tcp://")) { - try { - const parsed = parseConnectionUri(trimmed); - const endpoint = normalizeHostPort( - parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, - ); - const query = new URLSearchParams(); - if (parsed.useTls) { - query.set("ssl", "true"); - } - if (parsed.password) { - query.set("password", parsed.password); - } - const queryString = query.toString(); - const suffix = queryString ? `?${queryString}` : ""; - return `tcp://${endpoint}${suffix}`; - } catch { - return null; - } - } - - if ( - trimmed.startsWith("unix://") || - trimmed.startsWith("pipe://") || - trimmed.startsWith("\\\\.\\pipe\\") - ) { - return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed; - } - - if (trimmed.startsWith("/") || trimmed.startsWith("~")) { - return `unix://${trimmed}`; - } - - // Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets. - if (/^[A-Za-z]:[/\\]/.test(trimmed)) { - return null; - } - - if (/^\d+$/.test(trimmed)) { - return `127.0.0.1:${trimmed}`; - } - - return trimmed.includes(":") ? trimmed : null; -} - -export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string { - return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST; -} - -function isIpcDaemonHost(host: string | null): host is string { - return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://")); -} - -function isTcpDaemonHost(host: string | null): host is string { - return host !== null && !isIpcDaemonHost(host); -} - -function readPidSocketTarget(paseoHome: string): string | null { - const pidPath = path.join(paseoHome, PID_FILENAME); - if (!existsSync(pidPath)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as { - listen?: unknown; - sockPath?: unknown; - }; - if (typeof parsed.listen === "string") return parsed.listen; - if (typeof parsed.sockPath === "string") return parsed.sockPath; - return null; - } catch { - return null; - } -} - -function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); - if (isIpcDaemonHost(directEnvHost)) { - return directEnvHost; - } - - const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? ""); - if (isIpcDaemonHost(pidHost)) { - return pidHost; - } - - const config = loadConfig(paseoHome, { env }); - const configuredHost = normalizeDaemonHost(config.listen); - return isIpcDaemonHost(configuredHost) ? configuredHost : null; -} - -function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen); - if (!isTcpDaemonHost(configuredHost)) { - return null; - } - return configuredHost === "127.0.0.1:6767" ? null : configuredHost; -} - -export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { - const paseoHome = resolvePaseoHome(env); - const candidates: string[] = []; - const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome); - if (configuredIpcHost) { - candidates.push(configuredIpcHost); - } - const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome); - if (configuredTcpHost) { - candidates.push(configuredTcpHost); - } - candidates.push(DEFAULT_HOST); - return Array.from(new Set(candidates)); -} - -function resolveDaemonHostCandidates(options?: ConnectOptions): string[] { - const explicitHost = options?.host ?? process.env.PASEO_HOST; - if (explicitHost) { - return [explicitHost]; - } - - return resolveDefaultDaemonHosts(); -} - -function stripIpcPrefix(trimmed: string): string { - if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim(); - if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim(); - return trimmed; -} - -export function resolveDaemonTarget(host: string): DaemonTarget { - const trimmed = host.trim(); - if ( - trimmed.startsWith("unix://") || - trimmed.startsWith("pipe://") || - trimmed.startsWith("\\\\.\\pipe\\") - ) { - const socketPath = stripIpcPrefix(trimmed); - if (!socketPath) { - throw new Error("Invalid IPC daemon target: missing socket path"); - } - const isUnixSocket = trimmed.startsWith("unix://"); - return { - type: "ipc", - url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws", - socketPath, - }; - } - - if (trimmed.startsWith("tcp://")) { - const parsed = parseConnectionUri(trimmed); - const endpoint = normalizeHostPort( - parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, - ); - return { - type: "tcp", - url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }), - }; - } - - return { - type: "tcp", - url: `ws://${trimmed}/ws`, - }; -} - -export function resolveDaemonPassword(host: string): string | undefined { - const trimmed = host.trim(); - if (trimmed.startsWith("tcp://")) { - const fromUri = parseConnectionUri(trimmed).password; - if (fromUri) return fromUri; - } - const fromEnv = process.env.PASEO_PASSWORD; - return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; -} - -/** - * Create a WebSocket factory that works in Node.js - */ -function createNodeWebSocketFactory() { - return ( - url: string, - options?: { headers?: Record; protocols?: string[]; socketPath?: string }, - ): WebSocketLike => { - return new WebSocket(url, options?.protocols, { - headers: options?.headers, - ...(options?.socketPath ? { socketPath: options.socketPath } : {}), - }) as unknown as WebSocketLike; - }; -} - -/** - * Create and connect a daemon client - * Returns the connected client or throws if connection fails - */ -async function tryConnectHost( - host: string, - password: string | undefined, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise<{ client: DaemonClient } | { error: unknown }> { - const target = resolveDaemonTarget(host); - const client = new DaemonClient({ - url: target.url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - password, - connectTimeoutMs: timeout, - webSocketFactory: ( - url: string, - config?: { headers?: Record; protocols?: string[] }, - ) => - nodeWebSocketFactory(url, { - headers: config?.headers, - protocols: config?.protocols, - ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), - }), - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return { client }; - } catch (error) { - await client.close().catch(() => {}); - return { error }; - } -} - -async function connectViaRelayOffer( - offer: ConnectionOffer, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise { - const url = buildRelayWebSocketUrl({ - endpoint: offer.relay.endpoint, - serverId: offer.serverId, - role: "client", - useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), - }); - - const client = new DaemonClient({ - url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - connectTimeoutMs: timeout, - webSocketFactory: ( - target: string, - config?: { headers?: Record; protocols?: string[] }, - ) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }), - e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return client; - } catch (error) { - await client.close().catch(() => {}); - const message = error instanceof Error ? error.message : String(error); - const lastError = client.lastError ? ` (${client.lastError})` : ""; - throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error }); - } -} - -function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null { - if (!host) return null; - try { - return parseConnectionOfferFromUrl(host); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error }); - } -} - export async function connectToDaemon(options?: ConnectOptions): Promise { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const clientId = await getOrCreateCliClientId(); - const nodeWebSocketFactory = createNodeWebSocketFactory(); - - const explicitHost = options?.host ?? process.env.PASEO_HOST; - const offer = parseHostOfferOrNull(explicitHost); - if (offer) { - return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory); - } - - const hosts = resolveDaemonHostCandidates(options); - - async function tryNext(index: number, lastError: unknown): Promise { - if (index >= hosts.length) { - if (lastError instanceof Error) throw lastError; - throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`); - } - const host = hosts[index]; - const password = resolveDaemonPassword(host); - const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory); - if ("client" in result) { - return result.client; - } - return tryNext(index + 1, result.error); - } - - return tryNext(0, null); + return connectNodeClient({ + appVersion: resolveCliVersion(), + clientId: await getOrCreateCliClientId(), + clientType: "cli", + host: options?.host, + timeoutMs: options?.timeout, + }); } -/** - * Try to connect to the daemon, returns null if connection fails - */ export async function tryConnectToDaemon(options?: ConnectOptions): Promise { try { return await connectToDaemon(options); @@ -389,57 +68,21 @@ export async function tryConnectToDaemon(options?: ConnectOptions): Promise a.id === idOrName); - if (exactMatch) { - return exactMatch.id; - } - - // Try ID prefix match - const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query)); - if (prefixMatches.length === 1 && prefixMatches[0]) { - return prefixMatches[0].id; - } - - // Try title/name match (case-insensitive) - const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query); - if (titleMatches.length === 1 && titleMatches[0]) { - return titleMatches[0].id; - } - - // Try partial title match - const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query)); - if (partialTitleMatches.length === 1 && partialTitleMatches[0]) { - return partialTitleMatches[0].id; - } - - // If we have multiple prefix matches and no unique title match, return first prefix match - const firstPrefixMatch = prefixMatches[0]; - if (firstPrefixMatch) { - return firstPrefixMatch.id; - } - - return null; + const exact = agents.find((agent) => agent.id === idOrName); + if (exact) return exact.id; + const prefixes = agents.filter((agent) => agent.id.toLowerCase().startsWith(query)); + if (prefixes.length === 1) return prefixes[0]?.id ?? null; + const exactTitle = agents.filter((agent) => agent.title?.toLowerCase() === query); + if (exactTitle.length === 1) return exactTitle[0]?.id ?? null; + const partialTitle = agents.filter((agent) => agent.title?.toLowerCase().includes(query)); + if (partialTitle.length === 1) return partialTitle[0]?.id ?? null; + return prefixes[0]?.id ?? null; } diff --git a/packages/client/package.json b/packages/client/package.json index 1bc2c5c20..97e0ddb31 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -13,6 +13,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, "./internal/daemon-client": { "types": "./dist/daemon-client.d.ts", "default": "./dist/daemon-client.js" @@ -37,10 +41,12 @@ "dependencies": { "@getpaseo/protocol": "0.1.110", "@getpaseo/relay": "0.1.110", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.9.0", + "@types/ws": "^8.5.14", "typescript": "^5.2.2", "vitest": "^4.1.6" } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index ca115b4ee..3b3183862 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3,10 +3,6 @@ import { z } from "zod"; import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { - ProjectConfigImportSourceSchema, - type ProjectConfigImportSource, -} from "@getpaseo/protocol/messages"; import { decodeFileTransferFrame, encodeFileTransferFrame, @@ -29,11 +25,6 @@ expectTypeOf< "exploreFileSystem" extends keyof DaemonClient ? true : false >().toEqualTypeOf(); -const PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE: ProjectConfigImportSource = - ProjectConfigImportSourceSchema.options[0].parse({ - kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value, - }); - function createMockLogger() { return { debug: vi.fn(), @@ -3118,96 +3109,6 @@ test("writes project config via correlated RPC and returns inline failures", asy }); }); -test("previews and applies project config import via correlated RPC", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const previewPromise = client.getProjectConfigImport({ - requestId: "get-import-1", - repoRoot: "/repo/app", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - - expect(parseSentFrame(mock.sent[0])).toEqual({ - type: "project.config.get_import.request", - requestId: "get-import-1", - repoRoot: "/repo/app", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "project.config.get_import.response", - payload: { - requestId: "get-import-1", - repoRoot: "/repo/app", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - ok: true, - status: "available", - sourceRevision: "source-1", - paseoRevision: null, - inputs: [{ role: "shared", relativePath: "source/config.json" }], - items: [{ key: "worktree.setup", label: "Setup", outcome: "import" }], - preview: { worktree: { setup: "npm ci" } }, - }, - }), - ); - - await expect(previewPromise).resolves.toMatchObject({ - ok: true, - sourceRevision: "source-1", - preview: { worktree: { setup: "npm ci" } }, - }); - - const applyPromise = client.applyProjectConfigImport({ - requestId: "apply-import-1", - repoRoot: "/repo/app", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - expectedSourceRevision: "source-1", - expectedPaseoRevision: null, - }); - - expect(parseSentFrame(mock.sent[1])).toEqual({ - type: "project.config.apply_import.request", - requestId: "apply-import-1", - repoRoot: "/repo/app", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - expectedSourceRevision: "source-1", - expectedPaseoRevision: null, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "project.config.apply_import.response", - payload: { - requestId: "apply-import-1", - repoRoot: "/repo/app", - ok: false, - error: { code: "stale_source_config", source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE }, - }, - }), - ); - - await expect(applyPromise).resolves.toEqual({ - requestId: "apply-import-1", - repoRoot: "/repo/app", - ok: false, - error: { code: "stale_source_config", source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE }, - }); -}); - test("requests directory suggestions via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ccfd7934f..33a801b35 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -94,7 +94,6 @@ import type { SendAgentMessageRequest, PaseoConfigRaw, PaseoConfigRevision, - ProjectConfigImportSource, WorkspaceCreateRequest, WorkspaceRecoveryState, } from "@getpaseo/protocol/messages"; @@ -431,14 +430,6 @@ type WriteProjectConfigPayload = Extract< SessionOutboundMessage, { type: "write_project_config_response" } >["payload"]; -type GetProjectConfigImportPayload = Extract< - SessionOutboundMessage, - { type: "project.config.get_import.response" } ->["payload"]; -type ApplyProjectConfigImportPayload = Extract< - SessionOutboundMessage, - { type: "project.config.apply_import.response" } ->["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< @@ -451,18 +442,6 @@ export interface WriteProjectConfigInput { expectedRevision: PaseoConfigRevision | null; requestId?: string; } -export interface GetProjectConfigImportInput { - repoRoot: string; - source: ProjectConfigImportSource; - requestId?: string; -} -export interface ApplyProjectConfigImportInput { - repoRoot: string; - source: ProjectConfigImportSource; - expectedSourceRevision: string; - expectedPaseoRevision: PaseoConfigRevision | null; - requestId?: string; -} interface ListCommandsOptions { agentId: string; requestId?: string; @@ -4287,36 +4266,6 @@ export class DaemonClient { }); } - async getProjectConfigImport( - input: GetProjectConfigImportInput, - ): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: input.requestId, - message: { - type: "project.config.get_import.request", - repoRoot: input.repoRoot, - source: input.source, - }, - responseType: "project.config.get_import.response", - }); - } - - async applyProjectConfigImport( - input: ApplyProjectConfigImportInput, - ): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: input.requestId, - message: { - type: "project.config.apply_import.request", - repoRoot: input.repoRoot, - source: input.source, - expectedSourceRevision: input.expectedSourceRevision, - expectedPaseoRevision: input.expectedPaseoRevision, - }, - responseType: "project.config.apply_import.response", - }); - } - async refreshProvidersSnapshot(options?: { cwd?: string; providers?: AgentProvider[]; diff --git a/packages/client/src/node.test.ts b/packages/client/src/node.test.ts new file mode 100644 index 000000000..fc407f30a --- /dev/null +++ b/packages/client/src/node.test.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { + normalizeDaemonHost, + resolveDaemonPassword, + resolveDaemonTarget, + resolveDefaultDaemonHosts, +} from "./node.js"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const directory of cleanup.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +test("discovers the configured local socket before TCP fallback", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-")); + cleanup.push(paseoHome); + mkdirSync(paseoHome, { recursive: true }); + writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "/tmp/paseo.sock" })); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }), + ); + + expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ + "unix:///tmp/paseo.sock", + "127.0.0.1:7777", + "localhost:6767", + ]); +}); + +test("discovers the running daemon TCP address from its PID record", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-tcp-")); + cleanup.push(paseoHome); + writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:7789" })); + + expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([ + "127.0.0.1:7789", + "localhost:6767", + ]); +}); + +test("normalizes TCP, Unix, pipe, and Windows path-shaped targets", () => { + expect(normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=secret")).toBe( + "tcp://Example.com:6767?ssl=true&password=secret", + ); + expect(resolveDaemonTarget("unix:///tmp/paseo.sock")).toEqual({ + type: "ipc", + url: "ws+unix:///tmp/paseo.sock:/ws", + socketPath: "/tmp/paseo.sock", + }); + expect(normalizeDaemonHost("C:\\Users\\fixture\\paseo.sock")).toBeNull(); +}); + +test("keeps explicit and environment passwords process-local", () => { + expect(resolveDaemonPassword("tcp://localhost:6767?password=query-secret", {})).toBe( + "query-secret", + ); + expect(resolveDaemonPassword("localhost:6767", { PASEO_PASSWORD: "env-secret" })).toBe( + "env-secret", + ); +}); + +test("preserves the legacy PORT fallback when no listen setting exists", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-port-")); + cleanup.push(paseoHome); + + expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PORT: "7788" })).toEqual([ + "127.0.0.1:7788", + "localhost:6767", + ]); +}); + +test("skips malformed discovered candidates and keeps the default fallback", () => { + const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-malformed-")); + cleanup.push(paseoHome); + writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "tcp://bad" })); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { listen: "C:\\invalid\\socket" } }), + ); + + expect( + resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PASEO_LISTEN: "tcp://missing-port" }), + ).toEqual(["localhost:6767"]); +}); diff --git a/packages/client/src/node.ts b/packages/client/src/node.ts new file mode 100644 index 000000000..0d4a1c104 --- /dev/null +++ b/packages/client/src/node.ts @@ -0,0 +1,371 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + normalizeHostPort, + parseConnectionUri, + shouldUseTlsForDefaultHostedRelay, +} from "@getpaseo/protocol/daemon-endpoints"; +import { + parseConnectionOfferFromUrl, + type ConnectionOffer, +} from "@getpaseo/protocol/connection-offer"; +import type { PaseoConfigRaw, PaseoConfigRevision } from "@getpaseo/protocol/messages"; +import { WebSocket } from "ws"; +import { DaemonClient, type WebSocketLike } from "./daemon-client.js"; + +const DEFAULT_HOST = "localhost:6767"; +const DEFAULT_TIMEOUT_MS = 15_000; + +export interface NodeHostConnectionOptions { + appVersion: string; + clientId?: string; + clientType?: "cli" | "mcp"; + env?: NodeJS.ProcessEnv; + host?: string; + timeoutMs?: number; +} + +export interface HostAutomation { + addProject(rootPath: string): Promise; + openCheckout(path: string): Promise; + readProjectConfig(rootPath: string): Promise<{ + config: PaseoConfigRaw | null; + revision: PaseoConfigRevision | null; + }>; + writeProjectConfig(input: { + rootPath: string; + config: PaseoConfigRaw; + expectedRevision: PaseoConfigRevision | null; + }): Promise; + ensureCheckout(input: { + rootPath: string; + refName: string; + directoryName: string; + }): Promise<{ path: string; created: boolean }>; + close(): Promise; +} + +interface PersistedHostConfig { + daemon?: { listen?: unknown }; +} + +type DaemonTarget = { type: "tcp"; url: string } | { type: "ipc"; url: string; socketPath: string }; + +export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { + const configured = env.PASEO_HOME ?? "~/.paseo"; + const expanded = + configured === "~" ? os.homedir() : configured.replace(/^~\//, `${os.homedir()}/`); + return path.resolve(expanded); +} + +export function normalizeDaemonHost(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + if (value.startsWith("tcp://")) { + try { + const parsed = parseConnectionUri(value); + const endpoint = normalizeHostPort( + parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, + ); + const query = new URLSearchParams(); + if (parsed.useTls) query.set("ssl", "true"); + if (parsed.password) query.set("password", parsed.password); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return `tcp://${endpoint}${suffix}`; + } catch { + return null; + } + } + if (value.startsWith("unix://") || value.startsWith("pipe://")) return value; + if (value.startsWith("\\\\.\\pipe\\")) return `pipe://${value}`; + if (value.startsWith("/") || value.startsWith("~")) return `unix://${value}`; + if (/^[A-Za-z]:[/\\]/.test(value)) return null; + if (/^\d+$/.test(value)) return `127.0.0.1:${value}`; + return value.includes(":") ? value : null; +} + +function readConfiguredListen(paseoHome: string): string | null { + const configPath = path.join(paseoHome, "config.json"); + if (!existsSync(configPath)) return null; + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")) as PersistedHostConfig; + return typeof parsed.daemon?.listen === "string" ? parsed.daemon.listen : null; + } catch { + return null; + } +} + +function readPidListen(paseoHome: string): string | null { + const pidPath = path.join(paseoHome, "paseo.pid"); + if (!existsSync(pidPath)) return null; + try { + const parsed = JSON.parse(readFileSync(pidPath, "utf8")) as { + listen?: unknown; + sockPath?: unknown; + }; + if (typeof parsed.listen === "string") return parsed.listen; + return typeof parsed.sockPath === "string" ? parsed.sockPath : null; + } catch { + return null; + } +} + +export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { + const paseoHome = resolvePaseoHome(env); + const direct = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); + const pid = normalizeDaemonHost(readPidListen(paseoHome) ?? ""); + const configuredListen = readConfiguredListen(paseoHome); + const configured = normalizeDaemonHost(configuredListen ?? ""); + const port = + !env.PASEO_LISTEN && !configuredListen && /^\d+$/.test(env.PORT ?? "") + ? normalizeDaemonHost(env.PORT ?? "") + : null; + const rawCandidates = [direct, pid, configured].filter( + (candidate): candidate is string => candidate !== null, + ); + const ipc = rawCandidates.filter( + (candidate) => candidate.startsWith("unix://") || candidate.startsWith("pipe://"), + ); + const tcp = [direct, pid, configured, port].filter( + (candidate): candidate is string => + candidate !== null && + !candidate.startsWith("unix://") && + !candidate.startsWith("pipe://") && + candidate !== "127.0.0.1:6767", + ); + const candidates = [...ipc, ...tcp]; + candidates.push(DEFAULT_HOST); + return Array.from(new Set(candidates)); +} + +export function resolveDaemonTarget(host: string): DaemonTarget { + const value = host.trim(); + const isIpc = + value.startsWith("unix://") || value.startsWith("pipe://") || value.startsWith("\\\\.\\pipe\\"); + if (isIpc) { + const socketPath = value.replace(/^(?:unix|pipe):\/\//, "").trim(); + if (!socketPath) throw new Error("Invalid IPC daemon target: missing socket path"); + return { + type: "ipc", + url: value.startsWith("unix://") ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws", + socketPath, + }; + } + if (value.startsWith("tcp://")) { + const parsed = parseConnectionUri(value); + const endpoint = normalizeHostPort( + parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, + ); + return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }) }; + } + return { type: "tcp", url: `ws://${value}/ws` }; +} + +export function resolveDaemonPassword( + host: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (host.startsWith("tcp://")) { + const password = parseConnectionUri(host).password; + if (password) return password; + } + return env.PASEO_PASSWORD?.trim() || undefined; +} + +function createWebSocket( + target: DaemonTarget, +): ( + url: string, + options?: { headers?: Record; protocols?: string[] }, +) => WebSocketLike { + return (url, options) => + new WebSocket(url, options?.protocols, { + headers: options?.headers, + ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), + }) as unknown as WebSocketLike; +} + +async function connectCandidate( + host: string, + options: Required< + Pick + > & { + env: NodeJS.ProcessEnv; + }, +): Promise { + const target = resolveDaemonTarget(host); + const client = new DaemonClient({ + url: target.url, + clientId: options.clientId, + clientType: options.clientType, + appVersion: options.appVersion, + password: resolveDaemonPassword(host, options.env), + connectTimeoutMs: options.timeoutMs, + webSocketFactory: createWebSocket(target), + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } +} + +async function connectRelay( + offer: ConnectionOffer, + options: Required< + Pick + >, +): Promise { + const url = buildRelayWebSocketUrl({ + endpoint: offer.relay.endpoint, + serverId: offer.serverId, + role: "client", + useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), + }); + const client = new DaemonClient({ + url, + clientId: options.clientId, + clientType: options.clientType, + appVersion: options.appVersion, + connectTimeoutMs: options.timeoutMs, + webSocketFactory: createWebSocket({ type: "tcp", url }), + e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } +} + +export async function connectToDaemon(options: NodeHostConnectionOptions): Promise { + const env = options.env ?? process.env; + let hosts: string[]; + if (options.host) hosts = [options.host]; + else if (env.PASEO_HOST) hosts = [env.PASEO_HOST]; + else hosts = resolveDefaultDaemonHosts(env); + const identity = { + appVersion: options.appVersion, + clientId: options.clientId ?? `node-${randomUUID()}`, + clientType: options.clientType ?? ("cli" as const), + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }; + if (hosts.length === 1) { + const offer = parseOffer(hosts[0]); + if (offer) return connectRelay(offer, identity); + } + let lastError: unknown = new Error("No Paseo daemon targets were discovered."); + for (const host of hosts) { + try { + return await connectCandidate(host, { + ...identity, + env, + }); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +function parseOffer(host: string): ConnectionOffer | null { + try { + return parseConnectionOfferFromUrl(host); + } catch (error) { + const isPaseoUrl = host.startsWith("paseo://") || host.includes("/connect#"); + if (isPaseoUrl) throw error; + return null; + } +} + +class DaemonHostAutomation implements HostAutomation { + constructor(private readonly client: DaemonClient) {} + + async addProject(rootPath: string): Promise { + const result = await this.client.addProject(rootPath); + if (!result.project) throw new Error(result.error ?? `Unable to add ${rootPath}`); + } + + async openCheckout(checkoutPath: string): Promise { + const result = await this.client.openProject(checkoutPath); + if (!result.workspace) throw new Error(result.error ?? `Unable to open ${checkoutPath}`); + } + + async readProjectConfig(rootPath: string) { + const result = await this.client.readProjectConfig(rootPath); + if (!result.ok) throw new Error(`Unable to read project config: ${result.error.code}`); + return { config: result.config, revision: result.revision }; + } + + async writeProjectConfig(input: { + rootPath: string; + config: PaseoConfigRaw; + expectedRevision: PaseoConfigRevision | null; + }): Promise { + const result = await this.client.writeProjectConfig({ + repoRoot: input.rootPath, + config: input.config, + expectedRevision: input.expectedRevision, + }); + if (!result.ok) throw new Error(`Unable to write project config: ${result.error.code}`); + } + + async ensureCheckout(input: { + rootPath: string; + refName: string; + directoryName: string; + }): Promise<{ path: string; created: boolean }> { + const listed = await this.client.getPaseoWorktreeList({ repoRoot: input.rootPath }); + if (listed.error) + throw new Error(`Unable to inspect existing checkouts: ${listed.error.message}`); + const existing = listed.worktrees.find( + (worktree) => + worktree.branchName === input.refName && + path.basename(worktree.worktreePath) === input.directoryName, + ); + if (existing) { + const opened = await this.client.openProject(existing.worktreePath); + if (!opened.workspace) { + throw new Error(opened.error ?? `Unable to open ${existing.worktreePath}`); + } + return { path: existing.worktreePath, created: false }; + } + + const result = await this.client.createPaseoWorktree({ + cwd: input.rootPath, + worktreeSlug: input.directoryName, + action: "checkout", + refName: input.refName, + }); + const checkoutPath = result.workspace?.workspaceDirectory; + if (!checkoutPath) throw new Error(result.error ?? `Unable to check out ${input.refName}`); + return { path: checkoutPath, created: true }; + } + + close(): Promise { + return this.client.close(); + } +} + +export async function connectHostAutomation( + options: NodeHostConnectionOptions, +): Promise { + const client = await connectToDaemon(options); + if (client.getLastServerInfoMessage()?.features?.hostAutomation !== true) { + await client.close(); + throw new Error( + "This Paseo host does not support project migration. Update the host to use this.", + ); + } + return new DaemonHostAutomation(client); +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 85e907a67..cd7026f35 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,9 +15,10 @@ }, "main": "dist/main.js", "scripts": { - "build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml", + "build": "npm --prefix ../.. run build:server:clean && npm run build:clean --workspace=@getpaseo/migrate && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "capture-harness": "./capture-harness/run.sh", + "test:migration-electron": "npm --prefix ../.. run test:e2e:migration-electron --workspace=@getpaseo/app", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", @@ -26,6 +27,7 @@ }, "dependencies": { "@getpaseo/cli": "*", + "@getpaseo/migrate": "*", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", @@ -33,6 +35,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.56.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "41.2.0", diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 6393a5bb5..e1354f4d6 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -232,7 +232,7 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function resolveDesktopAppVersion(): string { +export function resolveDesktopAppVersion(): string { if (app.isPackaged) { return app.getVersion(); } diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8676f0087..5fdc10ebf 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -101,7 +101,7 @@ describe("desktop packaging", () => { }; const deps = pkg.dependencies ?? {}; - for (const required of ["@getpaseo/cli", "@getpaseo/server"]) { + for (const required of ["@getpaseo/cli", "@getpaseo/migrate", "@getpaseo/server"]) { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } }); diff --git a/packages/desktop/src/integrations/migrations/entrypoint.test.ts b/packages/desktop/src/integrations/migrations/entrypoint.test.ts new file mode 100644 index 000000000..80701c09c --- /dev/null +++ b/packages/desktop/src/integrations/migrations/entrypoint.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { resolveMigrationEntrypointFromPackage } from "./entrypoint.js"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const directory of cleanup.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +test("resolves the exact bundled migrator entrypoint and package version", () => { + const packageRoot = fixturePackage(true); + + expect(resolveMigrationEntrypointFromPackage({ packageRoot, isPackaged: true })).toEqual({ + version: "1.2.3", + entryPath: path.join(packageRoot, "dist", "cli.js"), + execArgv: [], + }); +}); + +test("uses the checked-out source entrypoint when development dist is absent", () => { + const packageRoot = fixturePackage(false); + + expect(resolveMigrationEntrypointFromPackage({ packageRoot, isPackaged: false })).toEqual({ + version: "1.2.3", + entryPath: path.join(packageRoot, "src", "cli.ts"), + execArgv: ["--import", "tsx"], + }); +}); + +function fixturePackage(includeDist: boolean): string { + const packageRoot = mkdtempSync(path.join(os.tmpdir(), "paseo-migration-package-")); + cleanup.push(packageRoot); + mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "1.2.3" })); + writeFileSync(path.join(packageRoot, "src", "cli.ts"), "export {};\n"); + if (includeDist) { + mkdirSync(path.join(packageRoot, "dist")); + writeFileSync(path.join(packageRoot, "dist", "cli.js"), "export {};\n"); + } + return packageRoot; +} diff --git a/packages/desktop/src/integrations/migrations/entrypoint.ts b/packages/desktop/src/integrations/migrations/entrypoint.ts new file mode 100644 index 000000000..6166abae7 --- /dev/null +++ b/packages/desktop/src/integrations/migrations/entrypoint.ts @@ -0,0 +1,52 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import type { NodeEntrypointSpec } from "../../daemon/node-entrypoint-launcher.js"; +import { assertPathExists, resolvePackagedAsarPath } from "../../daemon/package-paths.js"; + +export interface MigrationEntrypoint extends NodeEntrypointSpec { + version: string; +} + +export function resolveMigrationEntrypoint(): MigrationEntrypoint { + const packageRoot = app.isPackaged + ? path.join(resolvePackagedAsarPath(), "node_modules", "@getpaseo", "migrate") + : path.resolve(__dirname, "../../../../migrate"); + return resolveMigrationEntrypointFromPackage({ packageRoot, isPackaged: app.isPackaged }); +} + +export function resolveMigrationEntrypointFromPackage(input: { + packageRoot: string; + isPackaged: boolean; +}): MigrationEntrypoint { + const manifestPath = path.join(input.packageRoot, "package.json"); + const version = readVersion(manifestPath); + if (input.isPackaged) { + return { + version, + entryPath: assertPathExists({ + label: "Bundled migration entrypoint", + filePath: path.join(input.packageRoot, "dist", "cli.js"), + }), + execArgv: [], + }; + } + const distEntry = path.join(input.packageRoot, "dist", "cli.js"); + if (existsSync(distEntry)) return { version, entryPath: distEntry, execArgv: [] }; + return { + version, + entryPath: assertPathExists({ + label: "Migration source entrypoint", + filePath: path.join(input.packageRoot, "src", "cli.ts"), + }), + execArgv: ["--import", "tsx"], + }; +} + +function readVersion(manifestPath: string): string { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: unknown }; + if (typeof manifest.version !== "string" || manifest.version.trim().length === 0) { + throw new Error("Bundled migrator has no version."); + } + return manifest.version.trim(); +} diff --git a/packages/desktop/src/integrations/migrations/ipc.ts b/packages/desktop/src/integrations/migrations/ipc.ts new file mode 100644 index 000000000..44c355298 --- /dev/null +++ b/packages/desktop/src/integrations/migrations/ipc.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { ipcMain } from "electron"; +import { + resolveDesktopAppVersion, + resolveDesktopDaemonStatus, +} from "../../daemon/daemon-manager.js"; +import { createNodeEntrypointInvocation } from "../../daemon/runtime-paths.js"; +import type { NodeEntrypointInvocation } from "../../daemon/node-entrypoint-launcher.js"; +import { resolveMigrationEntrypoint } from "./entrypoint.js"; +import { DesktopMigrationProcess, type MigrationTarget } from "./process.js"; + +const migrations = new DesktopMigrationProcess({ + sources: new Map([["conductor", ["conductor"]]]), + env: process.env, + resolveEntrypoint: resolveMigrationEntrypoint, + createInvocation: ({ entrypoint, args, env }) => + createNodeEntrypointInvocation({ entrypoint, argvMode: "node-script", args, baseEnv: env }), + spawn: (invocation: NodeEntrypointInvocation) => + spawn(invocation.command, invocation.args, { + env: invocation.env, + stdio: ["ignore", "pipe", "pipe"], + }), + getTarget: async (): Promise => { + const status = await resolveDesktopDaemonStatus(); + return { + status: status.status, + desktopManaged: status.desktopManaged, + listen: status.listen, + home: status.home, + appVersion: resolveDesktopAppVersion(), + daemonVersion: status.version, + passwordProtected: Boolean(process.env.PASEO_PASSWORD?.trim()) || hasPassword(status.home), + }; + }, +}); + +export function registerMigrationIpc(): void { + ipcMain.handle("paseo:migrations:availability", (_event, input: unknown) => + migrations.availability(readSource(input)), + ); + ipcMain.handle("paseo:migrations:run", async (event, input: unknown) => { + const source = readSource(input); + return { + runId: await migrations.run(source, (output) => { + if (!event.sender.isDestroyed()) event.sender.send("paseo:migrations:output", output); + }), + }; + }); +} + +function readSource(input: unknown): string { + if (typeof input !== "object" || input === null || !("source" in input)) { + throw new Error("Migration source is required."); + } + const source = (input as { source?: unknown }).source; + if (typeof source !== "string") throw new Error("Migration source is required."); + return source; +} + +function hasPassword(paseoHome: string): boolean { + const configPath = path.join(paseoHome, "config.json"); + if (!existsSync(configPath)) return false; + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) as { + daemon?: { auth?: { password?: unknown } }; + }; + return typeof config.daemon?.auth?.password === "string"; + } catch { + return true; + } +} diff --git a/packages/desktop/src/integrations/migrations/process.test.ts b/packages/desktop/src/integrations/migrations/process.test.ts new file mode 100644 index 000000000..83e6971b9 --- /dev/null +++ b/packages/desktop/src/integrations/migrations/process.test.ts @@ -0,0 +1,196 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { expect, test } from "vitest"; +import { + DesktopMigrationProcess, + type DesktopMigrationOutput, + type MigrationTarget, +} from "./process.js"; + +const eligibleTarget: MigrationTarget = { + status: "running", + desktopManaged: true, + listen: "unix:///tmp/paseo.sock", + home: "/tmp/paseo-home", + appVersion: "0.1.110", + daemonVersion: "0.1.110", + passwordProtected: false, +}; + +test("launches the exact version-matched bundled command and streams completion", async () => { + const child = fakeChild(); + const invocations: Array<{ args: string[]; env: NodeJS.ProcessEnv }> = []; + const outputs: DesktopMigrationOutput[] = []; + const migrations = new DesktopMigrationProcess({ + env: { + HOME: "/Users/fixture", + PATH: "/usr/bin", + PASEO_PASSWORD: "must-not-leak", + PASEO_HOST: "tcp://remote:6767?password=must-not-leak", + PASEO_LISTEN: "0.0.0.0:6767", + AWS_SECRET_ACCESS_KEY: "arbitrary-credential", + NODE_OPTIONS: "--require=/tmp/untrusted.js", + }, + sources: new Map([["source-fixture", ["adapter-command"]]]), + getTarget: async () => eligibleTarget, + resolveEntrypoint: () => ({ + version: "0.1.110", + entryPath: "/app/@getpaseo/migrate/dist/cli.js", + execArgv: [], + }), + createInvocation: ({ args, env }) => { + invocations.push({ args, env }); + return { command: "/app/Paseo", args: ["runner.js", "node-script", "cli.js", ...args], env }; + }, + spawn: () => child.process, + }); + + const runId = await migrations.run("source-fixture", (output) => outputs.push(output)); + child.stdout.write("Found one project\n"); + child.stderr.write("warning\n"); + child.emitExit(0); + + expect(invocations).toEqual([ + { + args: ["adapter-command", "--yes"], + env: { + HOME: "/Users/fixture", + PATH: "/usr/bin", + PASEO_HOME: "/tmp/paseo-home", + }, + }, + ]); + expect(outputs).toEqual([ + { runId, stream: "stdout", chunk: "Found one project\n" }, + { runId, stream: "stderr", chunk: "warning\n" }, + { runId, stream: "status", exitCode: 0 }, + ]); + expect(invocations[0]?.env.PASEO_PASSWORD).toBeUndefined(); + expect(invocations[0]?.env.PASEO_HOST).toBeUndefined(); + expect(invocations[0]?.env.AWS_SECRET_ACCESS_KEY).toBeUndefined(); + expect(invocations[0]?.env.NODE_OPTIONS).toBeUndefined(); +}); + +test("allows only one migration process at a time", async () => { + const child = fakeChild(); + const migrations = createMigrations(eligibleTarget, child.process); + + await migrations.run("source-fixture", () => undefined); + await expect(migrations.run("source-fixture", () => undefined)).rejects.toThrow( + "A migration is already running.", + ); + child.emitExit(0); + await expect(migrations.run("source-fixture", () => undefined)).resolves.toEqual( + expect.any(String), + ); +}); + +test.each([ + [{ ...eligibleTarget, passwordProtected: true }, "password-protected"], + [{ ...eligibleTarget, listen: "10.0.0.5:6767" }, "nonlocal"], +] as const)("rejects an ineligible target before spawn", async (target, reason) => { + let spawned = false; + const child = fakeChild(); + const migrations = createMigrations(target, child.process, () => { + spawned = true; + }); + + await expect(migrations.run("source-fixture", () => undefined)).rejects.toThrow(reason); + expect(spawned).toBe(false); +}); + +test.each([ + [{ ...eligibleTarget, status: "stopped" as const }, "host-not-running"], + [{ ...eligibleTarget, listen: "10.0.0.5:6767" }, "nonlocal-host"], + [{ ...eligibleTarget, passwordProtected: true }, "password-protected"], + [{ ...eligibleTarget, daemonVersion: "0.1.109" }, "host-version-mismatch"], +] as const)("returns a localizable availability reason", async (target, reason) => { + const migrations = createMigrations(target, fakeChild().process); + + await expect(migrations.availability("source-fixture")).resolves.toEqual({ + available: false, + reason, + }); +}); + +test("reports a bundled package version mismatch before spawn", async () => { + const child = fakeChild(); + const migrations = createMigrations(eligibleTarget, child.process, undefined, "0.1.109"); + + await expect(migrations.run("source-fixture", () => undefined)).rejects.toThrow( + "bundled migrator version", + ); +}); + +test("returns a localizable bundled-version availability reason", async () => { + const migrations = createMigrations(eligibleTarget, fakeChild().process, undefined, "0.1.109"); + + await expect(migrations.availability("source-fixture")).resolves.toEqual({ + available: false, + reason: "migrator-version-mismatch", + }); +}); + +test("rejects an unregistered opaque source before target lookup or spawn", async () => { + let targetRead = false; + let spawned = false; + const child = fakeChild(); + const migrations = new DesktopMigrationProcess({ + env: {}, + sources: new Map([["known-source", ["known-adapter"]]]), + getTarget: async () => { + targetRead = true; + return eligibleTarget; + }, + resolveEntrypoint: () => ({ version: "0.1.110", entryPath: "/app/migrate.js", execArgv: [] }), + createInvocation: ({ env }) => ({ command: "/app/Paseo", args: [], env }), + spawn: () => { + spawned = true; + return child.process; + }, + }); + + await expect(migrations.run("unknown-source", () => undefined)).rejects.toThrow( + "Unsupported migration source: unknown-source", + ); + expect(targetRead).toBe(false); + expect(spawned).toBe(false); +}); + +function createMigrations( + target: MigrationTarget, + child: ChildProcess, + onSpawn?: () => void, + entrypointVersion = "0.1.110", +): DesktopMigrationProcess { + return new DesktopMigrationProcess({ + env: {}, + sources: new Map([["source-fixture", ["source-fixture"]]]), + getTarget: async () => target, + resolveEntrypoint: () => ({ + version: entrypointVersion, + entryPath: "/app/migrate.js", + execArgv: [], + }), + createInvocation: ({ env }) => ({ command: "/app/Paseo", args: [], env }), + spawn: () => { + onSpawn?.(); + return child; + }, + }); +} + +function fakeChild(): { + process: ChildProcess; + stdout: PassThrough; + stderr: PassThrough; + emitExit(code: number): void; +} { + const events = new EventEmitter(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const process = events as ChildProcess; + Object.assign(process, { stdout, stderr }); + return { process, stdout, stderr, emitExit: (code) => events.emit("exit", code, null) }; +} diff --git a/packages/desktop/src/integrations/migrations/process.ts b/packages/desktop/src/integrations/migrations/process.ts new file mode 100644 index 000000000..2a42c1e40 --- /dev/null +++ b/packages/desktop/src/integrations/migrations/process.ts @@ -0,0 +1,206 @@ +import type { ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import type { NodeEntrypointInvocation } from "../../daemon/node-entrypoint-launcher.js"; +import type { MigrationEntrypoint } from "./entrypoint.js"; + +export interface MigrationTarget { + status: "starting" | "running" | "stopped" | "errored"; + desktopManaged: boolean; + listen: string | null; + home: string; + appVersion: string; + daemonVersion: string | null; + passwordProtected: boolean; +} + +export interface DesktopMigrationOutput { + runId: string; + stream: "stdout" | "stderr" | "status"; + chunk?: string; + exitCode?: number; +} + +export type MigrationUnavailableReason = + | "unsupported-source" + | "host-not-running" + | "nonlocal-host" + | "password-protected" + | "host-version-mismatch" + | "migrator-version-mismatch" + | "unavailable"; + +interface MigrationProcessDependencies { + sources: ReadonlyMap; + getTarget(): Promise; + resolveEntrypoint(): MigrationEntrypoint; + createInvocation(input: { + entrypoint: MigrationEntrypoint; + args: string[]; + env: NodeJS.ProcessEnv; + }): NodeEntrypointInvocation; + spawn(invocation: NodeEntrypointInvocation): ChildProcess; + env: NodeJS.ProcessEnv; +} + +export class DesktopMigrationProcess { + private activeRunId: string | null = null; + + constructor(private readonly dependencies: MigrationProcessDependencies) {} + + async availability( + source: string, + ): Promise<{ available: boolean; reason: MigrationUnavailableReason | null }> { + try { + this.requireSourceArgs(source); + const target = await this.dependencies.getTarget(); + assertEligibleTarget(target); + const entrypoint = this.dependencies.resolveEntrypoint(); + if (normalizeVersion(entrypoint.version) !== normalizeVersion(target.appVersion)) { + throw new MigrationEligibilityError( + "migrator-version-mismatch", + "The bundled migrator version does not match Paseo Desktop.", + ); + } + return { available: true, reason: null }; + } catch (error) { + return { + available: false, + reason: error instanceof MigrationEligibilityError ? error.reason : "unavailable", + }; + } + } + + async run(source: string, emit: (output: DesktopMigrationOutput) => void): Promise { + const sourceArgs = this.requireSourceArgs(source); + if (this.activeRunId) throw new Error("A migration is already running."); + this.activeRunId = "starting"; + let child: ChildProcess; + let runId: string; + try { + const target = await this.dependencies.getTarget(); + assertEligibleTarget(target); + const entrypoint = this.dependencies.resolveEntrypoint(); + if (normalizeVersion(entrypoint.version) !== normalizeVersion(target.appVersion)) { + throw new MigrationEligibilityError( + "migrator-version-mismatch", + "The bundled migrator version does not match Paseo Desktop.", + ); + } + + runId = randomUUID(); + const env = migrationEnvironment(this.dependencies.env, target.home); + const invocation = this.dependencies.createInvocation({ + entrypoint, + args: [...sourceArgs, "--yes"], + env, + }); + child = this.dependencies.spawn(invocation); + this.activeRunId = runId; + } catch (error) { + this.activeRunId = null; + throw error; + } + let settled = false; + child.stdout?.on("data", (chunk: Buffer | string) => { + emit({ runId, stream: "stdout", chunk: chunk.toString() }); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + emit({ runId, stream: "stderr", chunk: chunk.toString() }); + }); + child.once("error", (error) => { + if (settled) return; + settled = true; + this.activeRunId = null; + emit({ runId, stream: "stderr", chunk: `${error.message}\n` }); + emit({ runId, stream: "status", exitCode: 1 }); + }); + child.once("exit", (code) => { + if (settled) return; + settled = true; + this.activeRunId = null; + emit({ runId, stream: "status", exitCode: code ?? 1 }); + }); + return runId; + } + + private requireSourceArgs(source: string): readonly string[] { + const args = this.dependencies.sources.get(source); + if (!args) { + throw new MigrationEligibilityError( + "unsupported-source", + `Unsupported migration source: ${source}`, + ); + } + return args; + } +} + +export function assertEligibleTarget(target: MigrationTarget): void { + if (target.status !== "running" || !target.desktopManaged) { + throw new MigrationEligibilityError( + "host-not-running", + "Import requires the running Paseo Desktop-managed host.", + ); + } + if (!isLocalListen(target.listen)) { + throw new MigrationEligibilityError( + "nonlocal-host", + "Import is unavailable for a nonlocal host.", + ); + } + if (target.passwordProtected) { + throw new MigrationEligibilityError( + "password-protected", + "Import is unavailable while the local host is password-protected.", + ); + } + if (normalizeVersion(target.daemonVersion) !== normalizeVersion(target.appVersion)) { + throw new MigrationEligibilityError( + "host-version-mismatch", + "Update the Desktop-managed host before importing.", + ); + } +} + +class MigrationEligibilityError extends Error { + constructor( + readonly reason: MigrationUnavailableReason, + message: string, + ) { + super(message); + } +} + +function isLocalListen(listen: string | null): boolean { + if (!listen) return false; + if (listen.startsWith("unix://") || listen.startsWith("pipe://") || listen.startsWith("/")) { + return true; + } + const endpoint = listen.replace(/^tcp:\/\//, "").toLowerCase(); + if (endpoint.startsWith("[::1]:")) return true; + const host = endpoint.split(":")[0]; + return host === "127.0.0.1" || host === "localhost" || host === "[::1]"; +} + +function migrationEnvironment(env: NodeJS.ProcessEnv, paseoHome: string): NodeJS.ProcessEnv { + const childEnv: NodeJS.ProcessEnv = { PASEO_HOME: paseoHome }; + for (const name of [ + "HOME", + "PATH", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "SystemRoot", + "WINDIR", + "USERPROFILE", + ]) { + if (env[name] !== undefined) childEnv[name] = env[name]; + } + return childEnv; +} + +function normalizeVersion(version: string | null): string | null { + return version?.trim().replace(/^v/i, "") || null; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index a75eb7106..f2df72603 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -88,6 +88,7 @@ import { runDesktopStartup } from "./desktop-startup.js"; import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; +import { registerMigrationIpc } from "./integrations/migrations/ipc.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -878,6 +879,7 @@ async function bootstrap(): Promise { registerOpenerHandlers(); registerEditorTargetHandlers(); registerBrowserAutomationIpc(); + registerMigrationIpc(); // In-app "Open in new window": opens a window that lands on the given project // via the same open-project flow as a CLI launch (no move, no ownership). diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index b409de37c..87f09ab50 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -17,6 +17,13 @@ interface AttachedBrowserRegistration { webContentsId: number; } +interface DesktopMigrationOutput { + runId: string; + stream: "stdout" | "stderr" | "status"; + chunk?: string; + exitCode?: number; +} + contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, invoke: (command: string, args?: Record) => @@ -34,6 +41,30 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }); }, }, + migrations: { + getAvailability: (input: { source: string }) => + ipcRenderer.invoke("paseo:migrations:availability", input) as Promise<{ + available: boolean; + reason: + | "unsupported-source" + | "host-not-running" + | "nonlocal-host" + | "password-protected" + | "host-version-mismatch" + | "migrator-version-mismatch" + | "unavailable" + | null; + }>, + run: (input: { source: string }) => + ipcRenderer.invoke("paseo:migrations:run", input) as Promise<{ runId: string }>, + onOutput: (handler: (output: DesktopMigrationOutput) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, output: DesktopMigrationOutput) => { + handler(output); + }; + ipcRenderer.on("paseo:migrations:output", listener); + return () => ipcRenderer.removeListener("paseo:migrations:output", listener); + }, + }, window: { openNew: (options?: { pendingOpenProjectPath?: string | null }) => ipcRenderer.invoke("paseo:window:openNew", options), diff --git a/packages/migrate/fixtures/conductor/conductor.db b/packages/migrate/fixtures/conductor/conductor.db new file mode 100644 index 0000000000000000000000000000000000000000..84fa10bb4b7a82a70a44906011be5557573f5a96 GIT binary patch literal 20480 zcmeI3L2uhO6o4tqZd_xw3Uq)@0cab-W^`Uw6h+Y#L6>=I7sN?6SJ@IEm?}%mqSlf; ziEcUsIkY;zz4RVEO8u^$~LP#xtppvBmzty}gDh-}#>HVx`jePjyyG`RCQfrx?@vk{ga_h7jpW3gVHddj=ks5&qo_u>!W?TY43jk;*?Djx9Hp6ap?B$a40&) zsTcc4a*lY-xCd^d)KDHZ_4s0pm-{#0HwT#EkJ<|N_neCM=m zPwYj?N?~)Dxkq6Tz@|609s9mLnh&L)+V|FXhYtNnvSEBSbu&K=rwgkK7;lKvS)7l| z+(%iWcnBpC<6Z#&tvgMVXv*K{^-~7>;Gs8h$CG57Q|whPnQP{G6eS1ux~z2gs_Np) zYQm$pekR7Au>=(sB!C2v01`j~NB{{S0VIF~kN^@u0vCb6mwLlmyCyx?yTXy}A47y7 zInD;F2fG!(Y@KbEKFRJ zdX=NUlFwTy$v#sdfd6V#Q*wpREUjVPx>jHAKG?i1XZy1>1(*GGmAP=B`igA^W15K= z0@R#&DHs@^bb@gof(4z`la7d|cyZ8K?ZnfFf{+H|>n%N5yDd5ww8LZ4tZh^1w~! zv}C*7k21Ug7VE}u#Q4kj11c;?00|%gB!C2v01`j~NB{{S0VIF~kif+!a7DK?>D}jX z5#7+cN}!?>6+^c!m%|Yv|6gleCB{$2p!KR1K@$rSKmter2_OL^fCP{L5$cG)3rOz@Pkl5#1Rszv_4X1pNA3*%SgPvdXn_qXFCM=Oy4 z5=18" + } +} diff --git a/packages/migrate/src/cli.test.ts b/packages/migrate/src/cli.test.ts new file mode 100644 index 000000000..4ad27961d --- /dev/null +++ b/packages/migrate/src/cli.test.ts @@ -0,0 +1,67 @@ +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { main } from "./cli.js"; + +test("cancels an apply unless the user confirms", async () => { + const terminal = createTerminal("no\n"); + + const exitCode = await main(["conductor"], terminal.io); + + expect(exitCode).toBe(0); + expect(terminal.stdout()).toContain("Import conductor projects into Paseo?"); + expect(terminal.stdout()).toContain("Migration cancelled."); + expect(terminal.stderr()).toBe(""); +}); + +test("confirmation continues into source validation and returns a failure exit", async () => { + const terminal = createTerminal("yes\n"); + + const exitCode = await main(["unknown-source"], terminal.io); + + expect(exitCode).toBe(1); + expect(terminal.stdout()).toContain("Import unknown-source projects into Paseo?"); + expect(terminal.stderr()).toContain("Unsupported migration source: unknown-source"); +}); + +test("dry-run prints project actions, notices, and a final summary without prompting", async () => { + const terminal = createTerminal(""); + const databasePath = fileURLToPath( + new URL("../fixtures/conductor/conductor.db", import.meta.url), + ); + + const exitCode = await main(["conductor", "--database", databasePath, "--dry-run"], terminal.io); + + expect(exitCode).toBe(0); + expect(terminal.stdout()).toContain("Dry-run plan:"); + expect(terminal.stdout()).toContain("Skipped hidden project"); + expect(terminal.stdout()).toContain("Dry-run summary:"); + expect(terminal.stdout()).not.toContain("Import conductor projects into Paseo?"); + expect(terminal.stderr()).toBe(""); +}); + +function createTerminal(input: string): { + io: { + stdin: NodeJS.ReadableStream; + stdout: Writable; + stderr: Writable; + }; + stdout(): string; + stderr(): string; +} { + let stdout = ""; + let stderr = ""; + return { + io: { + stdin: Readable.from([input]), + stdout: new Writable({ + write: (chunk, _encoding, done) => ((stdout += chunk.toString()), done()), + }), + stderr: new Writable({ + write: (chunk, _encoding, done) => ((stderr += chunk.toString()), done()), + }), + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} diff --git a/packages/migrate/src/cli.ts b/packages/migrate/src/cli.ts new file mode 100644 index 000000000..685eaebdc --- /dev/null +++ b/packages/migrate/src/cli.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { createInterface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; +import { migrate } from "./migrate.js"; +import { createStreamingOutput } from "./output.js"; +import { connectPaseo } from "./paseo.js"; +import { createMigrationSource } from "./sources/index.js"; +import type { PaseoMigrationPort } from "./types.js"; + +interface CliOptions { + sourceId: string; + host?: string; + databasePath?: string; + yes: boolean; + dryRun: boolean; +} + +interface CliIo { + stdin: NodeJS.ReadableStream; + stdout: NodeJS.WritableStream; + stderr: NodeJS.WritableStream; +} + +export async function main( + argv = process.argv.slice(2), + io: CliIo = { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, +): Promise { + const output = createStreamingOutput({ stdout: io.stdout, stderr: io.stderr }); + try { + const options = parseArgs(argv); + if (!options.dryRun && !options.yes && !(await confirmMigration(options.sourceId, io))) { + output({ level: "info", message: "Migration cancelled." }); + return 0; + } + const source = createMigrationSource({ + sourceId: options.sourceId, + databasePath: options.databasePath, + }); + const connection = options.dryRun + ? null + : await connectPaseo({ host: options.host, version: packageVersion() }); + try { + const result = await migrate({ + source, + paseo: connection ?? dryRunPaseoPort, + dryRun: options.dryRun, + output, + }); + return result.notices.some((notice) => notice.level === "error") ? 1 : 0; + } finally { + await connection?.close(); + } + } catch (error) { + output({ level: "error", message: error instanceof Error ? error.message : String(error) }); + return 1; + } +} + +function parseArgs(argv: string[]): CliOptions { + const sourceId = argv[0]; + if (!sourceId || sourceId.startsWith("-")) { + throw new Error( + "Usage: paseo-migrate [--host ] [--database ] [--yes] [--dry-run]", + ); + } + const options: CliOptions = { sourceId, yes: false, dryRun: false }; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--yes") options.yes = true; + else if (argument === "--dry-run") options.dryRun = true; + else if (argument === "--host") options.host = requiredValue(argv, ++index, "--host"); + else if (argument === "--database") { + options.databasePath = requiredValue(argv, ++index, "--database"); + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + return options; +} + +function requiredValue(argv: string[], index: number, option: string): string { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${option} requires a value.`); + return value; +} + +async function confirmMigration(sourceId: string, io: CliIo): Promise { + const prompt = createInterface({ input: io.stdin, output: io.stdout }); + try { + const answer = await prompt.question( + `Import ${sourceId} projects into Paseo? Source data will not be changed. [y/N] `, + ); + return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes"; + } finally { + prompt.close(); + } +} + +function packageVersion(): string { + const require = createRequire(import.meta.url); + const manifest = require("../package.json") as { version?: unknown }; + if (typeof manifest.version !== "string") throw new Error("Unable to resolve migrator version."); + return manifest.version; +} + +const dryRunPaseoPort: PaseoMigrationPort = { + addProject: async () => undefined, + openCheckout: async () => undefined, + readProjectConfig: async () => ({ config: null, revision: null }), + writeProjectConfig: async () => undefined, + ensureCheckout: async () => ({ path: "", created: false }), +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await main(); +} diff --git a/packages/migrate/src/migrate.test.ts b/packages/migrate/src/migrate.test.ts new file mode 100644 index 000000000..573e36400 --- /dev/null +++ b/packages/migrate/src/migrate.test.ts @@ -0,0 +1,211 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PaseoConfigRaw, PaseoConfigRevision } from "@getpaseo/protocol/messages"; +import { afterEach, expect, test } from "vitest"; +import { migrate } from "./migrate.js"; +import { createConductorSource } from "./sources/conductor/index.js"; +import type { MigrationEvent, PaseoMigrationPort } from "./types.js"; +import { openFixtureDatabase, saveFixtureDatabase } from "./sources/conductor/sqlite.fixture.js"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const target of cleanup.splice(0)) rmSync(target, { recursive: true, force: true }); +}); + +test("imports real catalog, config, and Git worktree shapes through observable Paseo state", async () => { + const fixture = await createInstallationFixture(); + const paseo = new InMemoryPaseo({ + [fixture.repo]: { + worktree: { setup: "keep existing setup" }, + scripts: { dev: { command: "keep existing dev" } }, + }, + }); + const events: MigrationEvent[] = []; + + const result = await migrate({ + source: createConductorSource({ databasePath: fixture.databasePath, platform: "win32" }), + paseo, + dryRun: false, + output: (event) => events.push(event), + }); + + expect([...paseo.projects]).toEqual([fixture.repo]); + expect(paseo.configs.get(fixture.repo)).toEqual({ + worktree: { setup: "keep existing setup", teardown: "npm run cleanup" }, + scripts: { + dev: { command: "keep existing dev" }, + test: { command: "npm test" }, + "db-run": { command: "npm run db" }, + }, + metadataGeneration: { + branchName: { instructions: "Name a branch" }, + title: { instructions: "Write a concise task title" }, + }, + }); + expect(paseo.openedCheckouts).toEqual([fixture.liveWorktree]); + expect(paseo.createdCheckouts).toEqual([ + { rootPath: fixture.repo, refName: "create-branch", directoryName: "missing-create" }, + ]); + expect(result.notices.map((notice) => notice.code)).toEqual( + expect.arrayContaining([ + "hidden-project", + "invalid-project", + "recoverable-from-commit", + "missing-workspace-ref", + "archived-workspace", + ]), + ); + expect(result.inventory.projects[0]?.config?.worktree?.setup).not.toContain("never-read"); + expect(events.map((event) => event.message).join("\n")).not.toContain("never-read"); + expect(events.map((event) => event.message)).toEqual( + expect.arrayContaining([ + `Registered project ${fixture.repo}.`, + `Updated project config for ${fixture.repo}.`, + `Adopted worktree ${fixture.liveWorktree}.`, + expect.stringMatching(/^Recreated worktree .+ from create-branch\.$/), + expect.stringMatching(/^Migration summary:/), + ]), + ); + + const secondEvents: MigrationEvent[] = []; + const second = await migrate({ + source: createConductorSource({ databasePath: fixture.databasePath }), + paseo, + dryRun: false, + output: (event) => secondEvents.push(event), + }); + expect(second.notices.some((notice) => notice.code === "project-apply-failed")).toBe(false); + expect(paseo.configWrites).toBe(1); + expect(secondEvents.map((event) => event.message)).toContain( + `Worktree ${path.join(fixture.repo, ".paseo", "missing-create")} already exists for create-branch.`, + ); +}); + +test("reports a revision-stale config write without retrying or replacing existing values", async () => { + const fixture = await createInstallationFixture(); + const paseo = new InMemoryPaseo({ [fixture.repo]: {} }); + paseo.rejectWrites = true; + + const result = await migrate({ + source: createConductorSource({ databasePath: fixture.databasePath }), + paseo, + dryRun: false, + output: () => undefined, + }); + + expect(paseo.configWrites).toBe(1); + expect(paseo.configs.get(fixture.repo)).toEqual({}); + expect(paseo.openedCheckouts).toEqual([fixture.liveWorktree]); + expect(paseo.createdCheckouts).toEqual([ + { rootPath: fixture.repo, refName: "create-branch", directoryName: "missing-create" }, + ]); + expect(result.notices).toContainEqual({ + code: "project-config-apply-failed", + level: "error", + message: `${fixture.repo}: stale_project_config`, + }); +}); + +class InMemoryPaseo implements PaseoMigrationPort { + readonly projects = new Set(); + readonly configs = new Map(); + readonly openedCheckouts: string[] = []; + readonly createdCheckouts: Array<{ + rootPath: string; + refName: string; + directoryName: string; + }> = []; + configWrites = 0; + rejectWrites = false; + + constructor(configs: Record) { + for (const [rootPath, config] of Object.entries(configs)) this.configs.set(rootPath, config); + } + + async addProject(rootPath: string): Promise { + this.projects.add(rootPath); + } + + async openCheckout(checkoutPath: string): Promise { + if (!this.openedCheckouts.includes(checkoutPath)) this.openedCheckouts.push(checkoutPath); + } + + async readProjectConfig(rootPath: string): Promise<{ + config: PaseoConfigRaw | null; + revision: PaseoConfigRevision | null; + }> { + return { config: this.configs.get(rootPath) ?? null, revision: { mtimeMs: 1, size: 1 } }; + } + + async writeProjectConfig(input: { + rootPath: string; + config: PaseoConfigRaw; + expectedRevision: PaseoConfigRevision | null; + }): Promise { + this.configWrites += 1; + if (this.rejectWrites) throw new Error("stale_project_config"); + this.configs.set(input.rootPath, input.config); + } + + async ensureCheckout(input: { + rootPath: string; + refName: string; + directoryName: string; + }): Promise<{ path: string; created: boolean }> { + const existing = this.createdCheckouts.find( + (checkout) => + checkout.refName === input.refName && checkout.directoryName === input.directoryName, + ); + if (existing) { + return { path: path.join(input.rootPath, ".paseo", input.directoryName), created: false }; + } + this.createdCheckouts.push(input); + return { path: path.join(input.rootPath, ".paseo", input.directoryName), created: true }; + } +} + +async function createInstallationFixture(): Promise<{ + databasePath: string; + repo: string; + liveWorktree: string; +}> { + const directory = realpathSync( + mkdtempSync(path.join(os.tmpdir(), "paseo-conductor-installation-")), + ); + cleanup.push(directory); + const repo = path.join(directory, "repo"); + const liveWorktree = path.join(directory, "live-worktree"); + execFileSync("git", ["init", "-b", "main", repo]); + execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo }); + writeFileSync(path.join(repo, "README.md"), "fixture\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { cwd: repo }); + const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim(); + execFileSync("git", ["branch", "live-branch"], { cwd: repo }); + execFileSync("git", ["branch", "create-branch"], { cwd: repo }); + execFileSync("git", ["worktree", "add", liveWorktree, "live-branch"], { cwd: repo }); + writeFileSync(path.join(liveWorktree, "dirty.txt"), "uncommitted fixture change\n"); + cpSync(fixturePath("current/.conductor"), path.join(repo, ".conductor"), { + recursive: true, + }); + + const databasePath = path.join(directory, "conductor.db"); + cpSync(fixturePath("conductor.db"), databasePath); + const database = await openFixtureDatabase(databasePath); + database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]); + database.run("UPDATE workspaces SET path = ? WHERE id = 'ready-live'", [liveWorktree]); + database.run("UPDATE workspaces SET archive_commit = ? WHERE id = 'recoverable'", [commit]); + saveFixtureDatabase(databasePath, database); + + expect(existsSync(liveWorktree)).toBe(true); + return { databasePath, repo: realpathSync(repo), liveWorktree: realpathSync(liveWorktree) }; +} + +function fixturePath(relativePath: string): string { + return fileURLToPath(new URL(`../fixtures/conductor/${relativePath}`, import.meta.url)); +} diff --git a/packages/migrate/src/migrate.ts b/packages/migrate/src/migrate.ts new file mode 100644 index 000000000..098c75d5b --- /dev/null +++ b/packages/migrate/src/migrate.ts @@ -0,0 +1,197 @@ +import { isDeepStrictEqual } from "node:util"; +import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; +import type { + MigrationInventory, + MigrationNotice, + MigrationOutput, + MigrationProject, + MigrationSource, + MigrationWorkspace, + PaseoMigrationPort, +} from "./types.js"; + +export interface MigrationResult { + inventory: MigrationInventory; + notices: MigrationNotice[]; +} + +interface MigrationStats { + configs: number; + adopted: number; + created: number; + existing: number; +} + +interface MigrationContext { + paseo: PaseoMigrationPort; + dryRun: boolean; + output: MigrationOutput; + stats: MigrationStats; + emitNotice(notice: MigrationNotice): void; +} + +export async function migrate(input: { + source: MigrationSource; + paseo: PaseoMigrationPort; + dryRun: boolean; + output: MigrationOutput; +}): Promise { + const inventory = await input.source.inspect(); + const notices: MigrationNotice[] = []; + const stats: MigrationStats = { configs: 0, adopted: 0, created: 0, existing: 0 }; + const emitNotice = (notice: MigrationNotice) => { + notices.push(notice); + input.output(notice); + }; + input.output({ + level: "info", + message: `${input.dryRun ? "Dry-run plan" : "Migration"}: ${inventory.projects.length} project(s) discovered.`, + }); + for (const notice of inventory.skippedSettings) emitNotice(notice); + + const context: MigrationContext = { ...input, stats, emitNotice }; + for (const project of inventory.projects) await migrateProject(project, context); + + const errors = notices.filter((notice) => notice.level === "error").length; + input.output({ + level: errors > 0 ? "error" : "info", + message: `${input.dryRun ? "Dry-run" : "Migration"} summary: ${inventory.projects.length} project(s), ${stats.configs} config update(s), ${stats.adopted} adopted, ${stats.created} recreated, ${stats.existing} already present, ${notices.length} notice(s), ${errors} error(s).`, + }); + return { inventory, notices }; +} + +async function migrateProject(project: MigrationProject, context: MigrationContext): Promise { + for (const notice of project.notices) context.emitNotice(notice); + if (context.dryRun) planProject(project, context); + else if (!(await applyProject(project, context))) return; + await migrateWorkspaces(project, context); +} + +function planProject(project: MigrationProject, context: MigrationContext): void { + context.output({ level: "info", message: `Would register project ${project.rootPath}.` }); + if (!project.config) return; + context.stats.configs += 1; + context.output({ + level: "info", + message: `Would merge supported project config for ${project.rootPath}.`, + }); +} + +async function applyProject( + project: MigrationProject, + context: MigrationContext, +): Promise { + try { + await context.paseo.addProject(project.rootPath); + context.output({ level: "info", message: `Registered project ${project.rootPath}.` }); + } catch (error) { + context.emitNotice(applyFailure("project-apply-failed", project.rootPath, error)); + return false; + } + try { + const current = await context.paseo.readProjectConfig(project.rootPath); + const merged = mergeExistingConfig(project.config, current.config); + if (isDeepStrictEqual(merged, current.config ?? {})) { + context.output({ + level: "info", + message: `Project config already current for ${project.rootPath}.`, + }); + return true; + } + await context.paseo.writeProjectConfig({ + rootPath: project.rootPath, + config: merged, + expectedRevision: current.revision, + }); + context.stats.configs += 1; + context.output({ level: "info", message: `Updated project config for ${project.rootPath}.` }); + } catch (error) { + context.emitNotice(applyFailure("project-config-apply-failed", project.rootPath, error)); + } + return true; +} + +async function migrateWorkspaces( + project: MigrationProject, + context: MigrationContext, +): Promise { + for (const workspace of project.workspaces) { + for (const notice of workspace.notices) context.emitNotice(notice); + if (context.dryRun) planWorkspace(workspace, context); + else await applyWorkspace(project.rootPath, workspace, context); + } +} + +function planWorkspace(workspace: MigrationWorkspace, context: MigrationContext): void { + if (workspace.disposition === "adopt" && workspace.path) { + context.stats.adopted += 1; + context.output({ level: "info", message: `Would adopt worktree ${workspace.path}.` }); + } else if (workspace.disposition === "create" && workspace.branch) { + context.stats.created += 1; + context.output({ + level: "info", + message: `Would ensure branch ${workspace.branch} at checkout ${workspace.directoryName}.`, + }); + } +} + +async function applyWorkspace( + rootPath: string, + workspace: MigrationWorkspace, + context: MigrationContext, +): Promise { + try { + if (workspace.disposition === "adopt" && workspace.path) { + await context.paseo.openCheckout(workspace.path); + context.stats.adopted += 1; + context.output({ level: "info", message: `Adopted worktree ${workspace.path}.` }); + } else if (workspace.disposition === "create" && workspace.branch) { + const ensured = await context.paseo.ensureCheckout({ + rootPath, + refName: workspace.branch, + directoryName: workspace.directoryName, + }); + context.stats[ensured.created ? "created" : "existing"] += 1; + context.output({ + level: "info", + message: ensured.created + ? `Recreated worktree ${ensured.path} from ${workspace.branch}.` + : `Worktree ${ensured.path} already exists for ${workspace.branch}.`, + }); + } + } catch (error) { + context.emitNotice(applyFailure("workspace-apply-failed", workspace.sourceId, error)); + } +} + +export function mergeExistingConfig( + imported: PaseoConfigRaw | null, + existing: PaseoConfigRaw | null, +): PaseoConfigRaw { + const merged = mergeRecords(imported ?? {}, existing ?? {}); + if (isRecord(imported?.scripts) && isRecord(existing?.scripts)) { + merged.scripts = { ...imported.scripts, ...existing.scripts }; + } + return merged as PaseoConfigRaw; +} + +function mergeRecords(base: Record, override: Record) { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + const baseValue = merged[key]; + merged[key] = isRecord(baseValue) && isRecord(value) ? mergeRecords(baseValue, value) : value; + } + return merged; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function applyFailure(code: string, subject: string, error: unknown): MigrationNotice { + return { code, level: "error", message: `${subject}: ${errorMessage(error)}` }; +} diff --git a/packages/migrate/src/output.ts b/packages/migrate/src/output.ts new file mode 100644 index 000000000..500a480cf --- /dev/null +++ b/packages/migrate/src/output.ts @@ -0,0 +1,11 @@ +import type { MigrationOutput } from "./types.js"; + +export function createStreamingOutput(streams: { + stdout: Pick; + stderr: Pick; +}): MigrationOutput { + return (event) => { + const stream = event.level === "error" ? streams.stderr : streams.stdout; + stream.write(`${event.level.toUpperCase()}: ${event.message}\n`); + }; +} diff --git a/packages/migrate/src/paseo.ts b/packages/migrate/src/paseo.ts new file mode 100644 index 000000000..61792da70 --- /dev/null +++ b/packages/migrate/src/paseo.ts @@ -0,0 +1,9 @@ +import { connectHostAutomation } from "@getpaseo/client/node"; +import type { PaseoMigrationPort } from "./types.js"; + +export async function connectPaseo(input: { + host?: string; + version: string; +}): Promise }> { + return connectHostAutomation({ appVersion: input.version, host: input.host }); +} diff --git a/packages/migrate/src/sources/conductor/database.test.ts b/packages/migrate/src/sources/conductor/database.test.ts new file mode 100644 index 000000000..0da22dced --- /dev/null +++ b/packages/migrate/src/sources/conductor/database.test.ts @@ -0,0 +1,107 @@ +import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, test } from "vitest"; +import { readConductorCatalog, UnsupportedConductorDatabaseError } from "./database.js"; +import { openFixtureDatabase, saveFixtureDatabase } from "./sqlite.fixture.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/conductor/conductor.db", import.meta.url), +); +const cleanup: string[] = []; + +afterEach(() => { + for (const target of cleanup.splice(0)) rmSync(target, { recursive: true, force: true }); +}); + +test("reads the sanitized current catalog without selecting sensitive columns", async () => { + const catalog = await readConductorCatalog(fixturePath); + + expect(catalog.repos.map((repo) => repo.rootPath)).toEqual([ + "/fixture/repo-current", + "/fixture/repo-hidden", + "C:\\Users\\fixture\\project", + ]); + expect(catalog.workspaces.map((workspace) => workspace.state)).toEqual([ + "ready", + "ready", + "ready", + "ready", + "archived", + ]); + expect(Object.keys(catalog.repos[0] ?? {}).sort()).toEqual([ + "databaseSettings", + "hidden", + "id", + "name", + "notices", + "rootPath", + ]); +}); + +test("accepts the current schema when optional project-config columns are absent", async () => { + const databasePath = temporaryDatabase(); + const database = await openFixtureDatabase(); + database.run(` + PRAGMA user_version = 112; + CREATE TABLE repos (id TEXT, root_path TEXT, name TEXT, is_hidden INTEGER); + CREATE TABLE workspaces (id TEXT, repo_id TEXT, branch TEXT, state TEXT, path TEXT, archive_commit TEXT); + INSERT INTO repos VALUES ('repo', '/tmp/repo', 'Repo', 0); + `); + saveFixtureDatabase(databasePath, database); + + expect((await readConductorCatalog(databasePath)).repos[0]?.databaseSettings).toEqual({}); +}); + +test("rejects an unsupported schema version before guessing at its contents", async () => { + const databasePath = temporaryDatabase(); + cpSync(fixturePath, databasePath); + const database = await openFixtureDatabase(databasePath); + database.run("PRAGMA user_version = 111"); + saveFixtureDatabase(databasePath, database); + + await expect(readConductorCatalog(databasePath)).rejects.toThrow( + UnsupportedConductorDatabaseError, + ); +}); + +test("reports malformed database JSON and script columns per project", async () => { + const databasePath = temporaryDatabase(); + cpSync(fixturePath, databasePath); + const database = await openFixtureDatabase(databasePath); + database.run( + "UPDATE repos SET run_scripts_json = ?, metadata_prompts_json = ?, setup_script = ? WHERE id = 'repo-current'", + ["{bad", "[]", new Uint8Array([42])], + ); + saveFixtureDatabase(databasePath, database); + + const repo = (await readConductorCatalog(databasePath)).repos.find( + (candidate) => candidate.id === "repo-current", + ); + + expect(repo?.databaseSettings).toEqual({ scripts: { archive: "db teardown" } }); + expect(repo?.notices).toEqual([ + { + code: "malformed-database-setting", + level: "warning", + message: "Skipped malformed run_scripts_json for project repo-current.", + }, + { + code: "malformed-database-setting", + level: "warning", + message: "Skipped malformed metadata_prompts_json for project repo-current.", + }, + { + code: "malformed-database-setting", + level: "warning", + message: "Skipped malformed setup_script for project repo-current.", + }, + ]); +}); + +function temporaryDatabase(): string { + const directory = mkdtempSync(path.join(os.tmpdir(), "paseo-migrate-db-")); + cleanup.push(directory); + return path.join(directory, "conductor.db"); +} diff --git a/packages/migrate/src/sources/conductor/database.ts b/packages/migrate/src/sources/conductor/database.ts new file mode 100644 index 000000000..8b458f554 --- /dev/null +++ b/packages/migrate/src/sources/conductor/database.ts @@ -0,0 +1,214 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import initSqlJs, { type Database } from "sql.js"; +import type { MigrationNotice } from "../../types.js"; +import type { ConductorSettings } from "./project-config.js"; + +const MINIMUM_SCHEMA_VERSION = 112; +const require = createRequire(import.meta.url); +const sqlite = initSqlJs({ + locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm"), +}); + +export interface ConductorRepoRecord { + id: string; + rootPath: string; + name: string | null; + hidden: boolean; + databaseSettings: ConductorSettings; + notices: MigrationNotice[]; +} + +export interface ConductorWorkspaceRecord { + id: string; + repoId: string; + branch: string | null; + state: string; + path: string | null; + archiveCommit: string | null; +} + +export interface ConductorCatalog { + repos: ConductorRepoRecord[]; + workspaces: ConductorWorkspaceRecord[]; +} + +export class UnsupportedConductorDatabaseError extends Error {} + +export async function readConductorCatalog(databasePath: string): Promise { + const SQL = await sqlite; + const database = new SQL.Database(readFileSync(databasePath)); + try { + const versionRow = queryRows(database, "PRAGMA user_version")[0]; + const version = numericColumn(versionRow, "user_version"); + if (version < MINIMUM_SCHEMA_VERSION) { + throw new UnsupportedConductorDatabaseError( + `Unsupported Conductor database schema ${version}; expected ${MINIMUM_SCHEMA_VERSION} or newer.`, + ); + } + + const repoColumns = tableColumns(database, "repos"); + requireColumns("repos", repoColumns, ["id", "root_path", "name", "is_hidden"]); + const workspaceColumns = tableColumns(database, "workspaces"); + requireColumns("workspaces", workspaceColumns, [ + "id", + "repo_id", + "branch", + "state", + "path", + "archive_commit", + ]); + + const optionalRepoColumns = [ + "setup_script", + "archive_script", + "run_scripts_json", + "metadata_prompts_json", + ].filter((column) => repoColumns.has(column)); + const selectedRepoColumns = ["id", "root_path", "name", "is_hidden", ...optionalRepoColumns]; + const repoRows = queryRows(database, `SELECT ${selectedRepoColumns.join(", ")} FROM repos`); + const workspaceRows = queryRows( + database, + "SELECT id, repo_id, branch, state, path, archive_commit FROM workspaces", + ); + + return { + repos: repoRows.map(parseRepo), + workspaces: workspaceRows.map(parseWorkspace), + }; + } finally { + database.close(); + } +} + +function queryRows(database: Database, sql: string): Record[] { + const result = database.exec(sql)[0]; + if (!result) return []; + return result.values.map((values) => + Object.fromEntries(result.columns.map((column, index) => [column, values[index] ?? null])), + ); +} + +function tableColumns(database: Database, table: string): Set { + const rows = queryRows(database, `PRAGMA table_info(${table})`); + if (rows.length === 0) throw new UnsupportedConductorDatabaseError(`Missing ${table} table.`); + return new Set(rows.map((row) => stringColumn(row, "name"))); +} + +function requireColumns(table: string, actual: Set, required: string[]): void { + const missing = required.filter((column) => !actual.has(column)); + if (missing.length > 0) { + throw new UnsupportedConductorDatabaseError( + `Unsupported ${table} schema; missing columns: ${missing.join(", ")}.`, + ); + } +} + +function parseRepo(value: unknown): ConductorRepoRecord { + const row = record(value); + const id = stringColumn(row, "id"); + const notices: MigrationNotice[] = []; + const scripts = parseRecordJson(row.run_scripts_json, id, "run_scripts_json", notices); + const prompts = parseRecordJson(row.metadata_prompts_json, id, "metadata_prompts_json", notices); + const setup = optionalConfigString(row.setup_script, id, "setup_script", notices); + const archive = optionalConfigString(row.archive_script, id, "archive_script", notices); + return { + id, + rootPath: stringColumn(row, "root_path"), + name: optionalString(row.name), + hidden: row.is_hidden === 1 || row.is_hidden === true, + databaseSettings: { + ...(setup || archive || scripts + ? { + scripts: { + ...(setup ? { setup } : {}), + ...(archive ? { archive } : {}), + ...(scripts ? { run: scripts } : {}), + }, + } + : {}), + ...(prompts ? { prompts } : {}), + }, + notices, + }; +} + +function parseWorkspace(value: unknown): ConductorWorkspaceRecord { + const row = record(value); + return { + id: stringColumn(row, "id"), + repoId: stringColumn(row, "repo_id"), + branch: optionalString(row.branch), + state: stringColumn(row, "state"), + path: optionalString(row.path), + archiveCommit: optionalString(row.archive_commit), + }; +} + +function parseRecordJson( + value: unknown, + repoId: string, + column: string, + notices: MigrationNotice[], +): Record | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + notices.push(malformedDatabaseSetting(repoId, column)); + return null; + } + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Report below with the same stable notice as a non-object JSON value. + } + notices.push(malformedDatabaseSetting(repoId, column)); + return null; +} + +function optionalConfigString( + value: unknown, + repoId: string, + column: string, + notices: MigrationNotice[], +): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") { + notices.push(malformedDatabaseSetting(repoId, column)); + return null; + } + return value.trim().length > 0 ? value : null; +} + +function malformedDatabaseSetting(repoId: string, column: string): MigrationNotice { + return { + code: "malformed-database-setting", + level: "warning", + message: `Skipped malformed ${column} for project ${repoId}.`, + }; +} + +function numericColumn(value: unknown, key: string): number { + const column = record(value)[key]; + if (typeof column !== "number") throw new UnsupportedConductorDatabaseError(`Invalid ${key}.`); + return column; +} + +function stringColumn(value: unknown, key: string): string { + const column = record(value)[key]; + if (typeof column !== "string") throw new UnsupportedConductorDatabaseError(`Invalid ${key}.`); + return column; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new UnsupportedConductorDatabaseError("Conductor database returned an invalid row."); + } + return value as Record; +} diff --git a/packages/migrate/src/sources/conductor/index.ts b/packages/migrate/src/sources/conductor/index.ts new file mode 100644 index 000000000..73cb92048 --- /dev/null +++ b/packages/migrate/src/sources/conductor/index.ts @@ -0,0 +1,43 @@ +import os from "node:os"; +import path from "node:path"; +import type { MigrationSource } from "../../types.js"; +import { readConductorCatalog } from "./database.js"; +import { inspectCatalog } from "./inspect.js"; + +export function defaultConductorDatabasePath(): string { + return path.join( + os.homedir(), + "Library", + "Application Support", + "com.conductor.app", + "conductor.db", + ); +} + +export function createConductorSource(input: { + databasePath?: string; + platform?: NodeJS.Platform; +}): MigrationSource { + return { + id: "conductor", + async inspect() { + const platform = input.platform ?? process.platform; + if (!input.databasePath && platform !== "darwin") { + return { + projects: [], + skippedSettings: [ + { + code: "unsupported-platform", + level: "error", + message: + "Automatic Conductor discovery is available only on macOS; use --database for recovery.", + }, + ], + }; + } + return inspectCatalog( + await readConductorCatalog(input.databasePath ?? defaultConductorDatabasePath()), + ); + }, + }; +} diff --git a/packages/migrate/src/sources/conductor/inspect.test.ts b/packages/migrate/src/sources/conductor/inspect.test.ts new file mode 100644 index 000000000..28f2e979e --- /dev/null +++ b/packages/migrate/src/sources/conductor/inspect.test.ts @@ -0,0 +1,106 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { ConductorCatalog } from "./database.js"; +import { inspectCatalog } from "./inspect.js"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const target of cleanup.splice(0)) rmSync(target, { recursive: true, force: true }); +}); + +test("isolates malformed config and preserves valid projects", () => { + const malformed = createRepository("malformed", "[scripts\ninvalid"); + const valid = createRepository("valid", '[scripts]\nsetup = "npm ci"\n'); + const catalog: ConductorCatalog = { + repos: [repoRecord("malformed", malformed), repoRecord("valid", valid)], + workspaces: [], + }; + + const inspected = inspectCatalog(catalog); + + expect(inspected.projects.map((project) => project.rootPath)).toEqual([malformed, valid]); + expect(inspected.projects[0]?.config).toBeNull(); + expect(inspected.projects[0]?.notices).toEqual([ + { + code: "malformed-project-config", + level: "warning", + message: `Skipped project config for ${malformed}: unable to read or parse .conductor/settings.toml.`, + }, + ]); + expect(inspected.projects[1]?.config).toEqual({ worktree: { setup: "npm ci" } }); +}); + +test("isolates unreadable config and reports its exact source path", () => { + const unreadable = createRepository("unreadable", null); + mkdirSync(path.join(unreadable, ".conductor", "settings.toml"), { recursive: true }); + + const inspected = inspectCatalog({ + repos: [repoRecord("unreadable", unreadable)], + workspaces: [], + }); + + expect(inspected.projects[0]?.config).toBeNull(); + expect(inspected.projects[0]?.notices).toEqual([ + { + code: "malformed-project-config", + level: "warning", + message: `Skipped project config for ${unreadable}: unable to read or parse .conductor/settings.toml.`, + }, + ]); +}); + +test("reports an unknown workspace state by its exact value", () => { + const repo = createRepository("unknown-state", null); + const inspected = inspectCatalog({ + repos: [repoRecord("repo", repo)], + workspaces: [ + { + id: "workspace-paused", + repoId: "repo", + branch: "main", + state: "paused", + path: null, + archiveCommit: null, + }, + ], + }); + + expect(inspected.projects[0]?.workspaces[0]).toMatchObject({ + sourceId: "workspace-paused", + state: "paused", + disposition: "invalid", + notices: [ + { + code: "unknown-workspace-state", + level: "warning", + message: 'Skipped workspace workspace-paused: unsupported state "paused".', + }, + ], + }); +}); + +function createRepository(name: string, settings: string | null): string { + const repo = mkdtempSync(path.join(os.tmpdir(), `paseo-inspect-${name}-`)); + cleanup.push(repo); + execFileSync("git", ["init", "-b", "main"], { cwd: repo }); + if (settings !== null) { + mkdirSync(path.join(repo, ".conductor"), { recursive: true }); + writeFileSync(path.join(repo, ".conductor", "settings.toml"), settings); + } + return realpathSync(repo); +} + +function repoRecord(id: string, rootPath: string) { + return { + id, + rootPath, + name: id, + hidden: false, + databaseSettings: {}, + notices: [], + }; +} diff --git a/packages/migrate/src/sources/conductor/inspect.ts b/packages/migrate/src/sources/conductor/inspect.ts new file mode 100644 index 000000000..191d56667 --- /dev/null +++ b/packages/migrate/src/sources/conductor/inspect.ts @@ -0,0 +1,230 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; +import type { MigrationNotice, MigrationProject, MigrationWorkspace } from "../../types.js"; +import type { + ConductorCatalog, + ConductorRepoRecord, + ConductorWorkspaceRecord, +} from "./database.js"; +import { + inspectConductorProjectConfig, + InvalidConductorProjectConfigError, +} from "./project-config.js"; + +export function inspectCatalog(catalog: ConductorCatalog): { + projects: MigrationProject[]; + skippedSettings: MigrationNotice[]; +} { + const projects: MigrationProject[] = []; + const skippedSettings: MigrationNotice[] = []; + const workspacesByRepo = new Map(); + for (const workspace of catalog.workspaces) { + const workspaces = workspacesByRepo.get(workspace.repoId) ?? []; + workspaces.push(workspace); + workspacesByRepo.set(workspace.repoId, workspaces); + } + + for (const repo of catalog.repos) { + if (repo.hidden) { + skippedSettings.push( + notice("hidden-project", `Skipped hidden project ${repo.name ?? repo.id}.`), + ); + continue; + } + if (!isGitRepository(repo.rootPath)) { + skippedSettings.push( + notice( + "invalid-project", + `Skipped ${repo.rootPath}: path is missing or is not a Git repository.`, + ), + ); + continue; + } + let projectConfig: ReturnType; + try { + projectConfig = inspectConductorProjectConfig(repo.rootPath, repo.databaseSettings); + } catch (error) { + const detail = invalidConfigDetail(error); + projectConfig = { + config: null, + notices: [ + notice( + "malformed-project-config", + `Skipped project config for ${repo.rootPath}: unable to read or parse ${detail}.`, + ), + ], + }; + } + projects.push({ + sourceId: repo.id, + rootPath: realpathSync(repo.rootPath), + config: projectConfig.config, + notices: [...repo.notices, ...projectConfig.notices], + workspaces: (workspacesByRepo.get(repo.id) ?? []).map((workspace) => + inspectWorkspace(repo, workspace), + ), + }); + } + return { projects, skippedSettings }; +} + +function invalidConfigDetail(error: unknown): string { + if (error instanceof InvalidConductorProjectConfigError) return error.relativePath; + if (error instanceof Error) return error.message; + return String(error); +} + +function inspectWorkspace( + repo: ConductorRepoRecord, + workspace: ConductorWorkspaceRecord, +): MigrationWorkspace { + const directoryName = safeDirectoryName(workspace); + if (workspace.state === "archived") { + return { + sourceId: workspace.id, + state: "archived", + path: workspace.path, + branch: workspace.branch, + archiveCommit: workspace.archiveCommit, + directoryName, + disposition: "archived", + notices: [notice("archived-workspace", `Skipped archived workspace ${workspace.id}.`)], + }; + } + + if (workspace.state !== "ready") { + return { + sourceId: workspace.id, + state: workspace.state, + path: workspace.path, + branch: workspace.branch, + archiveCommit: workspace.archiveCommit, + directoryName, + disposition: "invalid", + notices: [ + notice( + "unknown-workspace-state", + `Skipped workspace ${workspace.id}: unsupported state "${workspace.state}".`, + ), + ], + }; + } + + if (workspace.path && isDirectory(workspace.path)) { + const valid = isLinkedWorktree(repo.rootPath, workspace.path); + return { + sourceId: workspace.id, + state: "ready", + path: workspace.path, + branch: workspace.branch, + archiveCommit: workspace.archiveCommit, + directoryName, + disposition: valid ? "adopt" : "invalid", + notices: valid + ? [] + : [ + notice( + "invalid-worktree", + `Skipped ${workspace.path}: not linked to ${repo.rootPath}.`, + ), + ], + }; + } + + if (workspace.branch && refExists(repo.rootPath, workspace.branch)) { + return { + sourceId: workspace.id, + state: "ready", + path: workspace.path, + branch: workspace.branch, + archiveCommit: workspace.archiveCommit, + directoryName, + disposition: "create", + notices: [], + }; + } + + const recoverable = workspace.archiveCommit && refExists(repo.rootPath, workspace.archiveCommit); + return { + sourceId: workspace.id, + state: "ready", + path: workspace.path, + branch: workspace.branch, + archiveCommit: workspace.archiveCommit, + directoryName, + disposition: recoverable ? "recoverable-from-commit" : "missing-ref", + notices: [ + notice( + recoverable ? "recoverable-from-commit" : "missing-workspace-ref", + recoverable + ? `${workspace.id} can be recovered from commit ${workspace.archiveCommit}; no branch was invented.` + : `Skipped ${workspace.id}: no usable branch or archive commit exists.`, + ), + ], + }; +} + +function isGitRepository(rootPath: string): boolean { + if (!isDirectory(rootPath)) return false; + try { + return git(rootPath, ["rev-parse", "--is-inside-work-tree"]) === "true"; + } catch { + return false; + } +} + +function isLinkedWorktree(rootPath: string, workspacePath: string): boolean { + try { + const rootCommon = resolveGitPath(rootPath, git(rootPath, ["rev-parse", "--git-common-dir"])); + const workspaceCommon = resolveGitPath( + workspacePath, + git(workspacePath, ["rev-parse", "--git-common-dir"]), + ); + return realpathSync(rootCommon) === realpathSync(workspaceCommon); + } catch { + return false; + } +} + +function resolveGitPath(cwd: string, value: string): string { + return path.resolve(cwd, value); +} + +function refExists(rootPath: string, ref: string): boolean { + try { + git(rootPath, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +function safeDirectoryName(workspace: ConductorWorkspaceRecord): string { + const candidate = workspace.path ? path.basename(workspace.path.replaceAll("\\", "/")) : ""; + if (/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(candidate) && candidate !== "." && candidate !== "..") { + return candidate; + } + return `conductor-${workspace.id.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32)}`; +} + +function isDirectory(target: string): boolean { + if (!existsSync(target)) return false; + try { + return statSync(target).isDirectory(); + } catch { + return false; + } +} + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function notice(code: string, message: string): MigrationNotice { + return { code, level: "warning", message }; +} diff --git a/packages/migrate/src/sources/conductor/project-config.test.ts b/packages/migrate/src/sources/conductor/project-config.test.ts new file mode 100644 index 000000000..727a940cd --- /dev/null +++ b/packages/migrate/src/sources/conductor/project-config.test.ts @@ -0,0 +1,144 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, expect, test } from "vitest"; +import { inspectConductorProjectConfig } from "./project-config.js"; + +const cleanup: string[] = []; + +afterEach(() => { + for (const target of cleanup.splice(0)) rmSync(target, { recursive: true, force: true }); +}); + +test("maps current shared and local TOML with local precedence and no secret values", () => { + const repo = fixtureRepo("current"); + + const inspected = inspectConductorProjectConfig(repo, { + scripts: { setup: "database setup", run: { database: { command: "database run" } } }, + }); + + expect(inspected.config).toEqual({ + worktree: { setup: "npm ci --prefer-offline", teardown: "npm run cleanup" }, + scripts: { + database: { command: "database run" }, + dev: { command: "npm run dev -- --port $PASEO_PORT", type: "service" }, + test: { command: "npm test" }, + }, + metadataGeneration: { title: { instructions: "Write a concise task title" } }, + }); + expect(inspected.notices).toContainEqual({ + code: "conductor-setting-unsupported", + level: "warning", + message: + "environment_variables: Environment variable values are not imported. Found: SECRET_TOKEN.", + }); + expect(inspected.config?.worktree?.setup).not.toContain("fixture-secret-never-print"); + expect(inspected.notices.map((notice) => notice.message).join("\n")).not.toContain( + "fixture-secret-never-print", + ); +}); + +test("maps legacy conductor.json when TOML is absent", () => { + const inspected = inspectConductorProjectConfig(fixtureRepo("legacy")); + + expect(inspected.config).toEqual({ + worktree: { setup: "legacy setup", teardown: "legacy teardown" }, + scripts: { test: { command: "legacy test" } }, + }); +}); + +test("imports only commands whose cwd and Conductor variables have exact semantics", () => { + const repo = emptyRepo("safe-commands"); + writeSettings( + repo, + ` +unknown_project_setting = true + +[scripts] +setup = "echo $CONDUCTOR_WORKSPACE_NAME" + +[scripts.run.safe] +command = "npm test" + +[scripts.run.service] +command = "serve --port $CONDUCTOR_PORT" + +[scripts.run.absolute] +command = "npm start" +[scripts.run.absolute.options] +cwd = "/tmp/outside" + +[scripts.run.escape] +command = "npm start" +[scripts.run.escape.options] +cwd = "../outside" + +[scripts.run.unknown_variable] +command = "echo $CONDUCTOR_UNSUPPORTED_VALUE" +`, + ); + + const inspected = inspectConductorProjectConfig(repo); + + expect(inspected.config).toEqual({ + scripts: { + safe: { command: "npm test" }, + service: { command: "serve --port $PASEO_PORT", type: "service" }, + }, + }); + expect(inspected.notices.map((notice) => notice.message)).toEqual( + expect.arrayContaining([ + "worktree.setup: Unsupported Conductor variables: CONDUCTOR_WORKSPACE_NAME. Command was not imported.", + "scripts.absolute.cwd: Absolute or escaping cwd values are not imported.", + "scripts.escape.cwd: Absolute or escaping cwd values are not imported.", + "scripts.unknown_variable: Unsupported Conductor variables: CONDUCTOR_UNSUPPORTED_VALUE. Command was not imported.", + "settings.unknown_project_setting: Unknown Conductor setting.", + ]), + ); +}); + +test("reports malformed scripts instead of silently dropping them", () => { + const repo = emptyRepo("malformed-scripts"); + writeSettings( + repo, + ` +[scripts] +run = 42 +`, + ); + + const inspected = inspectConductorProjectConfig(repo); + + expect(inspected.config).toBeNull(); + expect(inspected.notices).toContainEqual({ + code: "conductor-setting-malformed", + level: "warning", + message: "scripts.run: Expected a command string or script table.", + }); +}); + +function fixtureRepo(name: "current" | "legacy"): string { + const directory = mkdtempSync(path.join(os.tmpdir(), `paseo-migrate-${name}-`)); + cleanup.push(directory); + cpSync( + fileURLToPath(new URL(`../../../fixtures/conductor/${name}`, import.meta.url)), + directory, + { + recursive: true, + }, + ); + return directory; +} + +function emptyRepo(name: string): string { + const directory = mkdtempSync(path.join(os.tmpdir(), `paseo-migrate-${name}-`)); + cleanup.push(directory); + return directory; +} + +function writeSettings(repo: string, contents: string): void { + const conductorDirectory = path.join(repo, ".conductor"); + mkdirSync(conductorDirectory, { recursive: true }); + writeFileSync(path.join(conductorDirectory, "settings.toml"), contents); +} diff --git a/packages/migrate/src/sources/conductor/project-config.ts b/packages/migrate/src/sources/conductor/project-config.ts new file mode 100644 index 000000000..e94404b97 --- /dev/null +++ b/packages/migrate/src/sources/conductor/project-config.ts @@ -0,0 +1,651 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, posix, relative } from "node:path"; +import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages"; +import { parse as parseToml } from "smol-toml"; +import type { MigrationNotice } from "../../types.js"; + +export class InvalidConductorProjectConfigError extends Error { + constructor(readonly relativePath: string) { + super(`Invalid Conductor project config: ${relativePath}`); + } +} + +interface SourceFile { + relativePath: string; + bytes: string; +} + +export interface ConductorSettings { + scripts?: { + setup?: unknown; + archive?: unknown; + run?: unknown; + run_mode?: unknown; + auto_run_after_setup?: unknown; + [key: string]: unknown; + }; + file_include_globs?: unknown; + environment_variables?: unknown; + environment_variables_forward?: unknown; + runScriptMode?: unknown; + enterprise_data_privacy?: unknown; + enterpriseDataPrivacy?: unknown; + prompts?: unknown; + git?: unknown; + spotlight_testing?: unknown; + [key: string]: unknown; +} + +interface ConductorRunScript { + command: string; + args?: string[]; + default?: boolean; + hide?: boolean; + icon?: string; + cwd?: string; + availableIn?: string | string[]; +} + +type RewriteContext = "lifecycle" | "run"; + +export function inspectConductorProjectConfig( + repoRoot: string, + databaseSettings: ConductorSettings = {}, +): { config: PaseoConfigRaw | null; notices: MigrationNotice[] } { + const settings = mergeSettings( + databaseSettings, + loadConductorSettings(discoverSources(repoRoot)), + ); + const config: PaseoConfigRaw = {}; + const notices: MigrationNotice[] = []; + + mapLifecycle(settings.scripts?.setup, "worktree.setup", "setup", config, notices); + mapLifecycle(settings.scripts?.archive, "worktree.teardown", "teardown", config, notices); + mapRunScripts(settings.scripts?.run, config, notices); + mapMetadataPrompts(settings.prompts, config, notices); + reportUnsupported(repoRoot, settings, notices); + + return { config: Object.keys(config).length > 0 ? config : null, notices }; +} + +function discoverSources(repoRoot: string): SourceFile[] { + const sharedToml = join(repoRoot, ".conductor", "settings.toml"); + const candidates = [ + ...(!existsSync(sharedToml) ? [join(repoRoot, "conductor.json")] : []), + join(repoRoot, ".conductor", "settings.json"), + sharedToml, + join(repoRoot, ".conductor", "settings.local.json"), + join(repoRoot, ".conductor", "settings.local.toml"), + ]; + return candidates.filter(existsSync).map((sourcePath) => { + const relativePath = relative(repoRoot, sourcePath).replaceAll("\\", "/"); + try { + return { relativePath, bytes: readFileSync(sourcePath, "utf8") }; + } catch { + throw new InvalidConductorProjectConfigError(relativePath); + } + }); +} + +function loadConductorSettings(sourceFiles: SourceFile[]): ConductorSettings { + let merged: ConductorSettings = {}; + for (const file of sourceFiles) { + let parsed: unknown; + try { + parsed = file.relativePath.endsWith(".json") ? JSON.parse(file.bytes) : parseToml(file.bytes); + } catch { + throw new InvalidConductorProjectConfigError(file.relativePath); + } + if (!isRecord(parsed)) throw new InvalidConductorProjectConfigError(file.relativePath); + merged = mergeSettings(merged, parsed as ConductorSettings); + } + return merged; +} + +function mergeSettings(base: ConductorSettings, override: ConductorSettings): ConductorSettings { + return { + ...base, + ...override, + scripts: { + ...(isRecord(base.scripts) ? base.scripts : {}), + ...(isRecord(override.scripts) ? override.scripts : {}), + run: mergeRunScripts(base.scripts?.run, override.scripts?.run), + }, + environment_variables: mergeNested(base.environment_variables, override.environment_variables), + environment_variables_forward: mergeNested( + base.environment_variables_forward, + override.environment_variables_forward, + ), + prompts: mergeNested(base.prompts, override.prompts), + }; +} + +function mergeNested(base: unknown, override: unknown): unknown { + if (!isRecord(base) || !isRecord(override)) return override ?? base; + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(override)) merged[key] = mergeNested(base[key], value); + return merged; +} + +function mergeRunScripts(base: unknown, override: unknown): unknown { + if (typeof override === "string") return override; + if (!isRecord(base) || !isRecord(override)) return override ?? base; + const merged: Record = { ...base }; + for (const [scriptId, entry] of Object.entries(override)) { + const previous = base[scriptId]; + merged[scriptId] = + isRecord(previous) && isRecord(entry) + ? { + ...previous, + ...entry, + ...(isRecord(previous.options) && isRecord(entry.options) + ? { options: { ...previous.options, ...entry.options } } + : {}), + } + : entry; + } + return merged; +} + +function mapLifecycle( + value: unknown, + key: string, + target: "setup" | "teardown", + config: PaseoConfigRaw, + notices: MigrationNotice[], +): void { + if (value === undefined) return; + if (typeof value !== "string" || value.trim().length === 0) { + notices.push(malformedSetting(key, "Expected a non-empty command string.")); + return; + } + const command = rewriteExactCommand(value, "lifecycle", key, notices); + if (!command) return; + config.worktree = { ...config.worktree, [target]: command }; +} + +function mapRunScripts( + runConfig: unknown, + config: PaseoConfigRaw, + notices: MigrationNotice[], +): void { + if (runConfig === undefined) return; + const services = new Map(); + if (typeof runConfig === "string") { + mapRunScript("run", { command: runConfig }, config, notices, services); + return; + } + if (!isRecord(runConfig)) { + notices.push(malformedSetting("scripts.run", "Expected a command string or script table.")); + return; + } + for (const scriptId of Object.keys(runConfig).sort()) { + const value = runConfig[scriptId]; + if (!isRecord(value)) { + notices.push(malformedSetting(`scripts.${scriptId}`, "Expected a script table.")); + continue; + } + const script = normalizeRunScript(scriptId, value, notices); + if (script) mapRunScript(scriptId, script, config, notices, services); + } +} + +function normalizeRunScript( + scriptId: string, + value: Record, + notices: MigrationNotice[], +): ConductorRunScript | null { + const key = `scripts.${scriptId}`; + reportUnknownKeys( + value, + new Set(["command", "args", "default", "hide", "icon", "options", "available_in"]), + key, + notices, + ); + if (typeof value.command !== "string" || value.command.trim().length === 0) { + notices.push(malformedSetting(key, "Expected a non-empty command string.")); + return null; + } + let args: string[] | undefined; + if (value.args !== undefined) { + if (!Array.isArray(value.args) || value.args.some((argument) => typeof argument !== "string")) { + notices.push(malformedSetting(`${key}.args`, "Expected only string arguments.")); + return null; + } + args = value.args as string[]; + } + const options = normalizeRunOptions(value.options, key, notices); + if (!options.valid) return null; + for (const [field, expected] of [ + ["default", "boolean"], + ["hide", "boolean"], + ["icon", "string"], + ] as const) { + if (value[field] !== undefined && typeof value[field] !== expected) { + notices.push(malformedSetting(`${key}.${field}`, `Expected a ${expected}.`)); + return null; + } + } + const availableIn = normalizeAvailableIn(value.available_in); + if (value.available_in !== undefined && !availableIn) { + notices.push( + malformedSetting(`${key}.available_in`, "Expected a string or an array of strings."), + ); + return null; + } + return { + command: value.command, + ...(args ? { args } : {}), + ...(typeof value.default === "boolean" ? { default: value.default } : {}), + ...(typeof value.hide === "boolean" ? { hide: value.hide } : {}), + ...(typeof value.icon === "string" ? { icon: value.icon } : {}), + ...(options.cwd ? { cwd: options.cwd } : {}), + ...(availableIn ? { availableIn } : {}), + }; +} + +function normalizeRunOptions( + value: unknown, + key: string, + notices: MigrationNotice[], +): { valid: boolean; cwd?: string } { + if (value === undefined) return { valid: true }; + if (!isRecord(value)) { + notices.push(malformedSetting(`${key}.options`, "Expected an options table.")); + return { valid: false }; + } + reportUnknownKeys(value, new Set(["cwd"]), `${key}.options`, notices); + if (value.cwd !== undefined && typeof value.cwd !== "string") { + notices.push(malformedSetting(`${key}.options.cwd`, "Expected a relative path string.")); + return { valid: false }; + } + return { valid: true, ...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}) }; +} + +function mapRunScript( + scriptId: string, + script: ConductorRunScript, + config: PaseoConfigRaw, + notices: MigrationNotice[], + serviceNames: Map, +): void { + const key = `scripts.${scriptId}`; + if (script.hide) { + notices.push(unsupportedSetting(key, "Hidden scripts are not imported.")); + return; + } + if (isCloudOnly(script.availableIn)) { + notices.push(unsupportedSetting(key, "Cloud-only scripts are not imported.")); + return; + } + if (script.default !== undefined) { + notices.push(unsupportedSetting(`${key}.default`, "Default script selection is not imported.")); + } + if (script.icon !== undefined) { + notices.push(unsupportedSetting(`${key}.icon`, "Script icons are not imported.")); + } + + let command = appendArgs(script.command, script.args ?? []); + if (script.cwd) { + const cwdPrefix = safeCwdPrefix(script.cwd); + if (!cwdPrefix) { + notices.push( + unsupportedSetting(`${key}.cwd`, "Absolute or escaping cwd values are not imported."), + ); + return; + } + command = `${cwdPrefix}${command}`; + } + if (containsArithmeticVariableOperation(command, "CONDUCTOR_PORT")) { + notices.push( + unsupportedSetting( + `${key}.port_arithmetic`, + "Conductor port arithmetic is not imported because Paseo reserves one service port.", + ), + ); + return; + } + + const service = containsShellVariable(command, "CONDUCTOR_PORT"); + if (service) { + const environmentName = scriptId.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + const collision = serviceNames.get(environmentName); + if (collision) { + notices.push( + unsupportedSetting( + key, + `Service environment name collides with "${collision}" (${environmentName}).`, + "conductor-setting-collision", + ), + ); + return; + } + serviceNames.set(environmentName, scriptId); + } + + const rewritten = rewriteExactCommand(command, service ? "run" : "lifecycle", key, notices); + if (!rewritten) return; + const entry: PaseoScriptEntryRaw = { command: rewritten }; + if (service) entry.type = "service"; + config.scripts = { ...config.scripts, [scriptId]: entry }; +} + +function rewriteExactCommand( + command: string, + context: RewriteContext, + key: string, + notices: MigrationNotice[], +): string | null { + let rewritten = command; + for (const [from, to] of [ + ["CONDUCTOR_WORKSPACE_PATH", "PASEO_WORKTREE_PATH"], + ["CONDUCTOR_ROOT_PATH", "PASEO_SOURCE_CHECKOUT_PATH"], + ["CONDUCTOR_PORT", context === "run" ? "PASEO_PORT" : "PASEO_WORKTREE_PORT"], + ] as const) { + rewritten = replaceShellVariable(rewritten, from, to); + } + const unsupported = collectConductorVariables(rewritten); + if (unsupported.length > 0) { + notices.push( + unsupportedSetting( + key, + `Unsupported Conductor variables: ${unsupported.join(", ")}. Command was not imported.`, + ), + ); + return null; + } + return rewritten; +} + +function reportUnsupported( + repoRoot: string, + settings: ConductorSettings, + notices: MigrationNotice[], +): void { + const unsupportedValues: Array<[string, unknown, string]> = [ + ["scripts.run_mode", settings.scripts?.run_mode, "Paseo has no project-wide run mode."], + ["runScriptMode", settings.runScriptMode, "Paseo has no project-wide run mode."], + [ + "scripts.auto_run_after_setup", + settings.scripts?.auto_run_after_setup, + "Paseo does not auto-run scripts after setup.", + ], + [ + "file_include_globs", + settings.file_include_globs, + "File include globs are not converted to shell copy commands.", + ], + [ + "spotlight_testing", + settings.spotlight_testing, + "Paseo spotlight is a separate workflow, not project config.", + ], + ["git", settings.git, "Conductor Git settings are not imported."], + [ + "enterprise_data_privacy", + settings.enterprise_data_privacy, + "Conductor enterprise data privacy settings are not imported.", + ], + [ + "enterpriseDataPrivacy", + settings.enterpriseDataPrivacy, + "Conductor enterprise data privacy settings are not imported.", + ], + ]; + for (const [key, value, detail] of unsupportedValues) { + if (value !== undefined) notices.push(unsupportedSetting(key, detail)); + } + if (existsSync(join(repoRoot, ".worktreeinclude"))) { + notices.push( + unsupportedSetting( + ".worktreeinclude", + "Worktree include patterns are not converted to shell copy commands.", + ), + ); + } + const environmentNames = collectEnvironmentVariableNames(settings); + if (environmentNames.length > 0) { + notices.push( + unsupportedSetting( + "environment_variables", + `Environment variable values are not imported. Found: ${environmentNames.join(", ")}.`, + ), + ); + } + reportUnsupportedPrompts(settings.prompts, notices); + for (const key of [ + "claude_code_executable_path", + "codex_executable_path", + "claude_provider", + "codex_provider", + "bedrock_region", + "vertex_project_id", + "ssh_key_path", + ]) { + if (settings[key] !== undefined) { + notices.push( + unsupportedSetting(key, "Conductor harness and provider settings are not imported."), + ); + } + } + reportUnknownKeys( + settings, + new Set([ + "scripts", + "file_include_globs", + "environment_variables", + "environment_variables_forward", + "runScriptMode", + "enterprise_data_privacy", + "enterpriseDataPrivacy", + "prompts", + "git", + "spotlight_testing", + "claude_code_executable_path", + "codex_executable_path", + "claude_provider", + "codex_provider", + "bedrock_region", + "vertex_project_id", + "ssh_key_path", + ]), + "settings", + notices, + ); + if (isRecord(settings.scripts)) { + reportUnknownKeys( + settings.scripts, + new Set(["setup", "archive", "run", "run_mode", "auto_run_after_setup"]), + "scripts", + notices, + ); + } +} + +function mapMetadataPrompts( + value: unknown, + config: PaseoConfigRaw, + notices: MigrationNotice[], +): void { + if (value === undefined) return; + if (!isRecord(value)) { + notices.push(malformedSetting("prompts", "Expected a prompt table.")); + return; + } + const mappings = { + title: "title", + branch_name: "branchName", + commit_message: "commitMessage", + pull_request: "pullRequest", + } as const; + for (const [sourceKey, targetKey] of Object.entries(mappings)) { + const instructions = value[sourceKey]; + if (instructions === undefined) continue; + if (typeof instructions !== "string" || instructions.trim().length === 0) { + notices.push(malformedSetting(`prompts.${sourceKey}`, "Expected non-empty instructions.")); + continue; + } + config.metadataGeneration = { + ...config.metadataGeneration, + [targetKey]: { instructions }, + }; + } +} + +function reportUnsupportedPrompts(value: unknown, notices: MigrationNotice[]): void { + if (!isRecord(value)) return; + const supported = new Set(["title", "branch_name", "commit_message", "pull_request"]); + const unknown = Object.keys(value).filter((key) => !supported.has(key)); + if (unknown.length > 0) { + notices.push( + unsupportedSetting("prompts", `Unsupported prompt keys: ${unknown.sort().join(", ")}.`), + ); + } +} + +function reportUnknownKeys( + value: Record, + known: Set, + prefix: string, + notices: MigrationNotice[], +): void { + for (const key of Object.keys(value) + .filter((candidate) => !known.has(candidate)) + .sort()) { + notices.push(unsupportedSetting(`${prefix}.${key}`, "Unknown Conductor setting.")); + } +} + +function collectEnvironmentVariableNames(settings: ConductorSettings): string[] { + const names = new Set(); + for (const key of ["environment_variables", "environment_variables_forward"] as const) { + collectNames(settings[key], names); + } + return [...names].sort(); +} + +function collectNames(value: unknown, names: Set): void { + if (Array.isArray(value)) { + for (const name of value) if (typeof name === "string") names.add(name); + return; + } + if (!isRecord(value)) return; + for (const [name, nested] of Object.entries(value)) { + if (isRecord(nested)) collectNames(nested, names); + else names.add(name); + } +} + +function unsupportedSetting( + key: string, + detail: string, + code = "conductor-setting-unsupported", +): MigrationNotice { + return { code, level: "warning", message: `${key}: ${detail}` }; +} + +function malformedSetting(key: string, detail: string): MigrationNotice { + return { code: "conductor-setting-malformed", level: "warning", message: `${key}: ${detail}` }; +} + +function replaceShellVariable(command: string, from: string, to: string): string { + const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g"); + return replaceArithmeticVariable( + command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`)), + from, + to, + ); +} + +function collectConductorVariables(command: string): string[] { + const names = new Set(); + for (const match of command.matchAll(/(?:\$\{|\$|[^A-Za-z0-9_])(CONDUCTOR_[A-Za-z0-9_]+)/g)) { + if (match[1]) names.add(match[1]); + } + return [...names].sort(); +} + +function containsShellVariable(command: string, name: string): boolean { + const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`); + return pattern.test(command) || containsArithmeticVariable(command, name); +} + +function replaceArithmeticVariable(command: string, from: string, to: string): string { + return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string) => { + const identifier = new RegExp(`(^|[^A-Za-z0-9_])${from}(?![A-Za-z0-9_])`, "g"); + const rewritten = body.replace(identifier, (_match, prefix: string) => `${prefix}${to}`); + return rewritten === body ? expression : `$((` + rewritten + `))`; + }); +} + +function containsArithmeticVariable(command: string, name: string): boolean { + const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); + for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { + if (identifier.test(match[1])) return true; + } + return false; +} + +function containsArithmeticVariableOperation(command: string, name: string): boolean { + const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); + for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { + const body = match[1].trim(); + if (identifier.test(body) && body !== name) return true; + } + return false; +} + +function appendArgs(command: string, args: string[]): string { + return args.length === 0 ? command : `${command} ${args.map(shellQuoteArgument).join(" ")}`; +} + +function safeCwdPrefix(cwd: string): string | null { + const normalized = posix.normalize(cwd.replaceAll("\\", "/")); + if ( + normalized.startsWith("/") || + /^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || + normalized === ".." || + normalized.startsWith("../") + ) { + return null; + } + return `cd -- ${shellQuote(normalized)} && `; +} + +function normalizeAvailableIn(value: unknown): string | string[] | undefined { + if (typeof value === "string") return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) { + return value as string[]; + } + return undefined; +} + +function isCloudOnly(value: string | string[] | undefined): boolean { + return ( + value === "cloud" || + (Array.isArray(value) && value.length > 0 && value.every((target) => target === "cloud")) + ); +} + +function shellQuoteArgument(value: string): string { + const variablePattern = + /\$\(\([\s\S]*?\)\)|\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?:(?:[^{}])|\{[^{}]*\})*\}|[A-Za-z_][A-Za-z0-9_]*)/g; + const parts: string[] = []; + let offset = 0; + for (const match of value.matchAll(variablePattern)) { + const index = match.index; + if (index > offset) parts.push(shellQuote(value.slice(offset, index))); + parts.push(`"${match[0]}"`); + offset = index + match[0].length; + } + if (offset < value.length) parts.push(shellQuote(value.slice(offset))); + return parts.length > 0 ? parts.join("") : shellQuote(value); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/packages/migrate/src/sources/conductor/sqlite.fixture.ts b/packages/migrate/src/sources/conductor/sqlite.fixture.ts new file mode 100644 index 000000000..c13acb378 --- /dev/null +++ b/packages/migrate/src/sources/conductor/sqlite.fixture.ts @@ -0,0 +1,21 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import type { Database, SqlJsStatic } from "sql.js"; + +const require = createRequire(import.meta.url); +const initSqlJs = require("sql.js") as (options?: { + locateFile?: (file: string) => string; +}) => Promise; +const sqlite = initSqlJs({ + locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm"), +}); + +export async function openFixtureDatabase(databasePath?: string): Promise { + const SQL = await sqlite; + return new SQL.Database(databasePath ? readFileSync(databasePath) : undefined); +} + +export function saveFixtureDatabase(databasePath: string, database: Database): void { + writeFileSync(databasePath, database.export()); + database.close(); +} diff --git a/packages/migrate/src/sources/index.ts b/packages/migrate/src/sources/index.ts new file mode 100644 index 000000000..b59d59c82 --- /dev/null +++ b/packages/migrate/src/sources/index.ts @@ -0,0 +1,12 @@ +import { createConductorSource } from "./conductor/index.js"; +import type { MigrationSource } from "../types.js"; + +export function createMigrationSource(input: { + sourceId: string; + databasePath?: string; +}): MigrationSource { + if (input.sourceId === "conductor") { + return createConductorSource({ databasePath: input.databasePath }); + } + throw new Error(`Unsupported migration source: ${input.sourceId}`); +} diff --git a/packages/migrate/src/types.ts b/packages/migrate/src/types.ts new file mode 100644 index 000000000..58b52ec73 --- /dev/null +++ b/packages/migrate/src/types.ts @@ -0,0 +1,70 @@ +import type { PaseoConfigRaw, PaseoConfigRevision } from "@getpaseo/protocol/messages"; + +export type MigrationNoticeLevel = "info" | "warning" | "error"; + +export interface MigrationNotice { + code: string; + level: MigrationNoticeLevel; + message: string; +} + +export interface MigrationWorkspace { + sourceId: string; + state: string; + path: string | null; + branch: string | null; + archiveCommit: string | null; + directoryName: string; + disposition: + | "adopt" + | "create" + | "recoverable-from-commit" + | "missing-ref" + | "archived" + | "invalid"; + notices: MigrationNotice[]; +} + +export interface MigrationProject { + sourceId: string; + rootPath: string; + config: PaseoConfigRaw | null; + workspaces: MigrationWorkspace[]; + notices: MigrationNotice[]; +} + +export interface MigrationInventory { + projects: MigrationProject[]; + skippedSettings: MigrationNotice[]; +} + +export interface MigrationSource { + id: string; + inspect(): Promise; +} + +export interface PaseoMigrationPort { + addProject(rootPath: string): Promise; + openCheckout(path: string): Promise; + readProjectConfig(rootPath: string): Promise<{ + config: PaseoConfigRaw | null; + revision: PaseoConfigRevision | null; + }>; + writeProjectConfig(input: { + rootPath: string; + config: PaseoConfigRaw; + expectedRevision: PaseoConfigRevision | null; + }): Promise; + ensureCheckout(input: { + rootPath: string; + refName: string; + directoryName: string; + }): Promise<{ path: string; created: boolean }>; +} + +export interface MigrationEvent { + level: MigrationNoticeLevel; + message: string; +} + +export type MigrationOutput = (event: MigrationEvent) => void; diff --git a/packages/migrate/tsconfig.json b/packages/migrate/tsconfig.json new file mode 100644 index 000000000..46db0e755 --- /dev/null +++ b/packages/migrate/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.fixture.ts"] +} diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 3b2997902..dec27d458 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -308,23 +308,6 @@ describe("agent detach RPC", () => { } expect(parsed.features?.importSessionWorkspaceTarget).toBe(true); }); - - test("parses future project config import source advertisements", () => { - const parsed = parseServerInfoStatusPayload({ - status: "server_info", - serverId: "srv-test", - features: { - projectConfigImportSources: [{ kind: "future-source", extra: { version: 2 } }], - }, - }); - - if (!parsed) { - throw new Error("Expected server info payload to parse"); - } - expect(parsed.features?.projectConfigImportSources).toEqual([ - { kind: "future-source", extra: { version: 2 } }, - ]); - }); }); describe("agent setting action responses", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5d0c9d85a..0dc878bc1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -66,21 +66,12 @@ import { PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, PaseoConfigRevisionSchema, - ProjectConfigImportAdvertisedSourceSchema, - ProjectConfigImportItemSchema, - ProjectConfigImportPreviewSchema, - ProjectConfigImportSourceSchema, ProjectConfigRpcErrorSchema, type PaseoConfigRaw, type PaseoConfigRevision, type PaseoMetadataGeneration, type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, - type ProjectConfigImportInput, - type ProjectConfigImportAdvertisedSource, - type ProjectConfigImportItem, - type ProjectConfigImportPreview, - type ProjectConfigImportSource, type ProjectConfigRpcError, } from "./paseo-config-schema.js"; export { @@ -90,20 +81,11 @@ export { PaseoMetadataGenerationSchema, PaseoScriptEntryRawSchema, PaseoWorktreeConfigRawSchema, - ProjectConfigImportAdvertisedSourceSchema, - ProjectConfigImportItemSchema, - ProjectConfigImportPreviewSchema, - ProjectConfigImportSourceSchema, type PaseoConfigRaw, type PaseoConfigRevision, type PaseoMetadataGeneration, type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, - type ProjectConfigImportInput, - type ProjectConfigImportAdvertisedSource, - type ProjectConfigImportItem, - type ProjectConfigImportPreview, - type ProjectConfigImportSource, type ProjectConfigRpcError, }; // --------------------------------------------------------------------------- @@ -1183,22 +1165,6 @@ export const WriteProjectConfigRequestMessageSchema = z.object({ expectedRevision: PaseoConfigRevisionSchema.nullable(), }); -export const GetProjectConfigImportRequestMessageSchema = z.object({ - type: z.literal("project.config.get_import.request"), - requestId: z.string(), - repoRoot: z.string(), - source: ProjectConfigImportSourceSchema, -}); - -export const ApplyProjectConfigImportRequestMessageSchema = z.object({ - type: z.literal("project.config.apply_import.request"), - requestId: z.string(), - repoRoot: z.string(), - source: ProjectConfigImportSourceSchema, - expectedSourceRevision: z.string(), - expectedPaseoRevision: PaseoConfigRevisionSchema.nullable(), -}); - // ============================================================================ // Dictation Streaming (lossless, resumable) // ============================================================================ @@ -2370,8 +2336,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetDaemonConfigRequestMessageSchema, ReadProjectConfigRequestMessageSchema, WriteProjectConfigRequestMessageSchema, - GetProjectConfigImportRequestMessageSchema, - ApplyProjectConfigImportRequestMessageSchema, DictationStreamStartMessageSchema, DictationStreamChunkMessageSchema, DictationStreamFinishMessageSchema, @@ -2704,14 +2668,14 @@ export const ServerInfoStatusPayloadSchema = z workspaceGithubRepositorySearch: z.boolean().optional(), // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. projectCreateDirectory: z.boolean().optional(), + // COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111. + hostAutomation: z.boolean().optional(), // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), - // COMPAT(projectConfigImportSources): added in v0.1.110, remove the gate after 2027-01-17. - projectConfigImportSources: z.array(ProjectConfigImportAdvertisedSourceSchema).optional(), // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. // Daemon advertises pluggable non-GitHub forge support (the forge registry); // the client gates non-GitHub setup UI on it. @@ -3662,47 +3626,6 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ ]), }); -export const GetProjectConfigImportResponseMessageSchema = z.object({ - type: z.literal("project.config.get_import.response"), - // zod-aot 0.2.0 miscompiles boolean discriminators as string options - // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. - payload: z.union([ - ProjectConfigImportPreviewSchema.extend({ - requestId: z.string(), - ok: z.literal(true), - }), - z.object({ - requestId: z.string(), - repoRoot: z.string(), - ok: z.literal(false), - error: ProjectConfigRpcErrorSchema, - }), - ]), -}); - -export const ApplyProjectConfigImportResponseMessageSchema = z.object({ - type: z.literal("project.config.apply_import.response"), - // zod-aot 0.2.0 miscompiles boolean discriminators as string options - // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. - payload: z.union([ - z.object({ - requestId: z.string(), - repoRoot: z.string(), - source: ProjectConfigImportSourceSchema, - ok: z.literal(true), - config: PaseoConfigRawSchema, - revision: PaseoConfigRevisionSchema, - items: z.array(ProjectConfigImportItemSchema), - }), - z.object({ - requestId: z.string(), - repoRoot: z.string(), - ok: z.literal(false), - error: ProjectConfigRpcErrorSchema, - }), - ]), -}); - export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ @@ -4925,8 +4848,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetDaemonConfigResponseMessageSchema, ReadProjectConfigResponseMessageSchema, WriteProjectConfigResponseMessageSchema, - GetProjectConfigImportResponseMessageSchema, - ApplyProjectConfigImportResponseMessageSchema, SetAgentModeResponseMessageSchema, SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, diff --git a/packages/protocol/src/paseo-config-schema.ts b/packages/protocol/src/paseo-config-schema.ts index f0447197b..804e1cdf0 100644 --- a/packages/protocol/src/paseo-config-schema.ts +++ b/packages/protocol/src/paseo-config-schema.ts @@ -79,71 +79,13 @@ export const PaseoConfigRevisionSchema = z.object({ size: z.number(), }); -export const ProjectConfigImportSourceSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("conductor") }), -]); - -export const ProjectConfigImportAdvertisedSourceSchema = z - .object({ kind: z.string().min(1) }) - .passthrough(); - -export const ProjectConfigImportInputSchema = z.object({ - role: z.string(), - relativePath: z.string(), -}); - -export const ProjectConfigImportItemOutcomeSchema = z.enum([ - "import", - "rewrite", - "collision", - "unsupported", -]); - -export const ProjectConfigImportItemSchema = z.object({ - key: z.string(), - label: z.string(), - outcome: ProjectConfigImportItemOutcomeSchema, - detail: z.string().optional(), -}); - -export const ProjectConfigImportStatusSchema = z.enum([ - "available", - "not_found", - "nothing_to_import", -]); - -export const ProjectConfigImportPreviewSchema = z.object({ - repoRoot: z.string(), - source: ProjectConfigImportSourceSchema, - status: ProjectConfigImportStatusSchema, - sourceRevision: z.string().nullable(), - paseoRevision: PaseoConfigRevisionSchema.nullable(), - inputs: z.array(ProjectConfigImportInputSchema), - items: z.array(ProjectConfigImportItemSchema), - preview: PaseoConfigRawSchema.nullable(), -}); - export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [ z.object({ code: z.literal("project_not_found") }), - z.object({ - code: z.literal("source_config_not_found"), - source: ProjectConfigImportSourceSchema, - }), - z.object({ - code: z.literal("invalid_source_config"), - source: ProjectConfigImportSourceSchema, - relativePath: z.string(), - }), - z.object({ - code: z.literal("stale_source_config"), - source: ProjectConfigImportSourceSchema, - }), z.object({ code: z.literal("invalid_project_config") }), z.object({ code: z.literal("stale_project_config"), currentRevision: PaseoConfigRevisionSchema.nullable(), }), - z.object({ code: z.literal("nothing_to_import") }), z.object({ code: z.literal("write_failed") }), ]); @@ -153,11 +95,4 @@ export type PaseoMetadataGeneration = z.infer; export type PaseoConfig = z.infer; export type PaseoConfigRevision = z.infer; -export type ProjectConfigImportSource = z.infer; -export type ProjectConfigImportAdvertisedSource = z.infer< - typeof ProjectConfigImportAdvertisedSourceSchema ->; -export type ProjectConfigImportInput = z.infer; -export type ProjectConfigImportItem = z.infer; -export type ProjectConfigImportPreview = z.infer; export type ProjectConfigRpcError = z.infer; diff --git a/packages/server/package.json b/packages/server/package.json index e7a4b3f4f..5b5b3755a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -50,7 +50,8 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", + "pretest:integration": "npm --prefix ../.. run build --workspace=@getpaseo/migrate", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/migration-host-automation.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", "test:integration:local": "vitest run local.e2e.test.ts", @@ -92,7 +93,6 @@ "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", "sherpa-onnx-node": "1.12.28", - "smol-toml": "^1.6.0", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "uuid": "^9.0.1", @@ -101,6 +101,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@getpaseo/migrate": "0.1.110", "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", diff --git a/packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts b/packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts new file mode 100644 index 000000000..6e2b61dc8 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts @@ -0,0 +1,194 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { connectHostAutomation, connectToDaemon } from "@getpaseo/client/node"; +import { migrate } from "@getpaseo/migrate"; +import { afterEach, expect, test } from "vitest"; +import { hashDaemonPassword } from "../auth.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const cleanupPaths = new Set(); +const cleanupDaemons = new Set(); +const cleanupConnections = new Set<{ close(): Promise }>(); + +afterEach(async () => { + await Promise.all([...cleanupConnections].map((connection) => connection.close())); + cleanupConnections.clear(); + await Promise.all([...cleanupDaemons].map((daemon) => daemon.close())); + cleanupDaemons.clear(); + for (const target of cleanupPaths) rmSync(target, { recursive: true, force: true }); + cleanupPaths.clear(); +}); + +test("two complete migrations reuse the same real daemon checkout", async () => { + const repoRoot = createRepository(); + const daemon = await createTestPaseoDaemon(); + cleanupDaemons.add(daemon); + const paseo = await connectHostAutomation({ + appVersion: "0.1.110", + clientId: "migration-idempotence-e2e", + env: {}, + host: `127.0.0.1:${daemon.port}`, + }); + cleanupConnections.add(paseo); + const source = { + id: "fixture", + inspect: async () => ({ + skippedSettings: [], + projects: [ + { + sourceId: "project", + rootPath: repoRoot, + config: null, + notices: [], + workspaces: [ + { + sourceId: "workspace", + state: "ready", + path: null, + branch: "feature", + archiveCommit: null, + directoryName: "imported-feature", + disposition: "create" as const, + notices: [], + }, + ], + }, + ], + }), + }; + + const first = await migrate({ source, paseo, dryRun: false, output: () => undefined }); + const second = await migrate({ source, paseo, dryRun: false, output: () => undefined }); + + expect(first.notices).toEqual([]); + expect(second.notices).toEqual([]); + expect(listFeatureWorktrees(repoRoot)).toEqual([ + expect.objectContaining({ branch: "refs/heads/feature", path: expect.any(String) }), + ]); + expect(path.basename(listFeatureWorktrees(repoRoot)[0]?.path ?? "")).toBe("imported-feature"); +}); + +test("a different live checkout continues to protect its branch", async () => { + const repoRoot = createRepository(); + const existingPath = path.join(path.dirname(repoRoot), "existing-feature"); + execFileSync("git", ["worktree", "add", existingPath, "feature"], { cwd: repoRoot }); + const daemon = await createTestPaseoDaemon(); + cleanupDaemons.add(daemon); + const paseo = await connectHostAutomation({ + appVersion: "0.1.110", + clientId: "migration-live-protection-e2e", + env: {}, + host: `127.0.0.1:${daemon.port}`, + }); + cleanupConnections.add(paseo); + + await expect( + paseo.ensureCheckout({ + rootPath: repoRoot, + refName: "feature", + directoryName: "different-feature", + }), + ).rejects.toThrow(/already checked out|in use/i); + expect(realpathSync(existingPath)).toBe(existingPath); +}); + +test("the public connector authenticates to a real password-protected daemon and closes cleanly", async () => { + const daemon = await createTestPaseoDaemon({ + auth: { password: hashDaemonPassword("connector-secret") }, + }); + cleanupDaemons.add(daemon); + + await expect( + connectToDaemon({ + appVersion: "0.1.110", + clientId: "migration-auth-failure-e2e", + env: { PASEO_PASSWORD: "wrong-secret" }, + host: `127.0.0.1:${daemon.port}`, + timeoutMs: 2_000, + }), + ).rejects.toThrow(/auth|password|unauthorized/i); + + const client = await connectToDaemon({ + appVersion: "0.1.110", + clientId: "migration-auth-success-e2e", + env: { PASEO_PASSWORD: "connector-secret" }, + host: `127.0.0.1:${daemon.port}`, + }); + const beforeClose = await client.fetchAgents(); + await client.close(); + + expect(beforeClose.entries).toEqual([]); + await expect(client.fetchAgents()).rejects.toThrow(); +}); + +test("the public connector uses PORT fallback and skips malformed discovered candidates", async () => { + const daemon = await createTestPaseoDaemon(); + cleanupDaemons.add(daemon); + const paseoHome = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-connector-home-"))); + cleanupPaths.add(paseoHome); + writeFileSync(path.join(paseoHome, "paseo.pid"), '{"listen":"tcp://missing-port"}'); + + const client = await connectToDaemon({ + appVersion: "0.1.110", + clientId: "migration-port-fallback-e2e", + env: { PASEO_HOME: paseoHome, PORT: String(daemon.port) }, + }); + cleanupConnections.add(client); + + expect((await client.fetchAgents()).entries).toEqual([]); +}); + +test.skipIf(process.platform === "win32")( + "the public connector reaches a real daemon through its Unix socket", + async () => { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-connector-ipc-"))); + cleanupPaths.add(parent); + const socketPath = path.join(parent, "paseo.sock"); + const daemon = await createTestPaseoDaemon({ listen: socketPath, allowIpc: true }); + cleanupDaemons.add(daemon); + expect(daemon.listenTarget).toEqual({ type: "socket", path: socketPath }); + expect(existsSync(socketPath)).toBe(true); + + const client = await connectToDaemon({ + appVersion: "0.1.110", + clientId: "migration-ipc-e2e", + env: {}, + host: `unix://${socketPath}`, + }); + cleanupConnections.add(client); + + expect((await client.fetchAgents()).entries).toEqual([]); + }, +); + +function createRepository(): string { + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-migration-daemon-"))); + cleanupPaths.add(parent); + const repoRoot = path.join(parent, "repo"); + execFileSync("git", ["init", "-b", "main", repoRoot]); + execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repoRoot }); + writeFileSync(path.join(repoRoot, "README.md"), "fixture\n"); + execFileSync("git", ["add", "."], { cwd: repoRoot }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { + cwd: repoRoot, + }); + execFileSync("git", ["branch", "feature"], { cwd: repoRoot }); + return realpathSync(repoRoot); +} + +function listFeatureWorktrees(repoRoot: string): Array<{ path: string; branch: string }> { + const entries = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoRoot, + encoding: "utf8", + }) + .trim() + .split("\n\n"); + return entries.flatMap((entry) => { + const worktreePath = entry.match(/^worktree (.+)$/m)?.[1]; + const branch = entry.match(/^branch (.+)$/m)?.[1]; + return worktreePath && branch === "refs/heads/feature" ? [{ path: worktreePath, branch }] : []; + }); +} diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 7b5a1ab54..99e0232bd 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -3,7 +3,7 @@ import { WebSocket } from "ws"; import pino from "pino"; import { Writable } from "node:stream"; import net from "node:net"; -import path from "node:path"; +import { fileURLToPath } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; @@ -21,6 +21,7 @@ import { import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer"; import { WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; +import { connectToDaemon } from "@getpaseo/client/node"; const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0"); const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25; @@ -172,7 +173,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis const startRelay = async () => { relayStdoutLines = []; relayPort = await getAvailablePort(); - const relayDir = path.resolve(process.cwd(), "../relay"); + const relayDir = fileURLToPath(new URL("../../../../relay", import.meta.url)); relayProcess = spawn( "npx", [ @@ -362,6 +363,41 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis } }, 90000); + test("public Node connector follows a real encrypted relay offer", async () => { + await startRelay(); + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, + }); + let client: Awaited> | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + + client = await connectToDaemon({ + appVersion: "0.1.110", + clientId: "public-node-relay-e2e", + env: {}, + host: offerUrl, + timeoutMs: 20_000, + }); + + expect((await client.fetchAgents()).entries).toEqual([]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, 90_000); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4991d9087..2416c77e3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1611,10 +1611,6 @@ export class Session { return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": return this.projectConfigSession.handleWriteProjectConfigRequest(msg); - case "project.config.get_import.request": - return this.projectConfigSession.handleGetProjectConfigImportRequest(msg); - case "project.config.apply_import.request": - return this.projectConfigSession.handleApplyProjectConfigImportRequest(msg); default: return undefined; } diff --git a/packages/server/src/server/session/project-config/import/merge.ts b/packages/server/src/server/session/project-config/import/merge.ts deleted file mode 100644 index 34b6715d1..000000000 --- a/packages/server/src/server/session/project-config/import/merge.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; -import { normalizeServiceEnvName } from "../../../workspace-service-env.js"; -import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./service.js"; - -interface MergeProjectConfigImportInput { - repoRoot: string; - source: ProjectConfigImportPreview["source"]; - candidate: ProjectConfigImportCandidate | null; - paseoConfig: PaseoConfigRaw; - paseoRevision: ProjectConfigImportPreview["paseoRevision"]; -} - -export function mergeProjectConfigImport( - input: MergeProjectConfigImportInput, -): ProjectConfigImportPreview { - if (!input.candidate) { - return { - repoRoot: input.repoRoot, - source: input.source, - status: "not_found", - sourceRevision: null, - paseoRevision: input.paseoRevision, - inputs: [], - items: [], - preview: null, - }; - } - - const base = input.paseoConfig; - const merged: PaseoConfigRaw = { ...base }; - const items = input.candidate.items.map((item) => ({ ...item })); - let importedCount = 0; - - const patchWorktree = input.candidate.patch.worktree ?? {}; - for (const key of ["setup", "teardown"] as const) { - if (!Object.hasOwn(patchWorktree, key)) continue; - if (hasLifecycle(base.worktree?.[key])) { - setOutcome(items, `worktree.${key}`, `Paseo already has ${key} commands.`); - continue; - } - merged.worktree = { ...merged.worktree, [key]: patchWorktree[key] }; - importedCount += 1; - } - - const patchScripts = input.candidate.patch.scripts ?? {}; - const serviceScriptByEnvName = collectServiceScriptsByEnvName(base.scripts ?? {}); - for (const [scriptId, script] of Object.entries(patchScripts)) { - const key = `scripts.${scriptId}`; - if (base.scripts && Object.hasOwn(base.scripts, scriptId)) { - setOutcome(items, key, `Paseo already has a "${scriptId}" script.`); - continue; - } - if (isServiceScript(script)) { - const envName = normalizeServiceEnvName(scriptId); - const existingScriptId = serviceScriptByEnvName.get(envName); - if (existingScriptId) { - setOutcome( - items, - key, - `Service environment name collides with "${existingScriptId}" (${envName}).`, - ); - continue; - } - serviceScriptByEnvName.set(envName, scriptId); - } - merged.scripts = { ...merged.scripts, [scriptId]: script }; - importedCount += 1; - } - - return { - repoRoot: input.repoRoot, - source: input.source, - status: importedCount > 0 ? "available" : "nothing_to_import", - sourceRevision: input.candidate.sourceRevision, - paseoRevision: input.paseoRevision, - inputs: input.candidate.inputs, - items, - preview: importedCount > 0 ? merged : null, - }; -} - -function collectServiceScriptsByEnvName( - scripts: NonNullable, -): Map { - const result = new Map(); - for (const [scriptId, script] of Object.entries(scripts)) { - if (isServiceScript(script)) { - result.set(normalizeServiceEnvName(scriptId), scriptId); - } - } - return result; -} - -function isServiceScript(script: unknown): boolean { - return Boolean( - script && typeof script === "object" && "type" in script && script.type === "service", - ); -} - -function hasLifecycle(value: unknown): boolean { - if (typeof value === "string") { - return value.trim().length > 0; - } - if (Array.isArray(value)) { - return value.some((entry) => typeof entry === "string" && entry.trim().length > 0); - } - return false; -} - -function setOutcome(items: ProjectConfigImportPreview["items"], key: string, detail: string): void { - const item = items.find((entry) => entry.key === key); - if (!item) { - items.push({ - key, - label: key, - outcome: "collision", - detail, - }); - return; - } - item.outcome = "collision"; - item.detail = detail; -} diff --git a/packages/server/src/server/session/project-config/import/registry.test.ts b/packages/server/src/server/session/project-config/import/registry.test.ts deleted file mode 100644 index 71fb0b723..000000000 --- a/packages/server/src/server/session/project-config/import/registry.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - createProjectConfigImportRegistry, - productionProjectConfigImportSourceSet, - projectConfigImportRegistry, - type ProjectConfigImportAdapter, - type ProjectConfigImportSourceSet, -} from "./registry.js"; - -interface FakeSource { - kind: "fake-source"; - profile: string; -} - -const fakeSourceSet = { - parse: (source) => - source.kind === "fake-source" && typeof source.profile === "string" - ? { kind: "fake-source", profile: source.profile } - : null, - kinds: () => ["fake-source"], -} satisfies ProjectConfigImportSourceSet; - -const fakeAdapter = { - source: { kind: "fake-source", profile: "alpha" }, - inspect: () => null, -} satisfies ProjectConfigImportAdapter; - -describe("project config import adapter registry", () => { - test("enumerates every advertised source from registered adapters", () => { - const productionSource = productionProjectConfigImportSourceSet.parse({ - kind: productionProjectConfigImportSourceSet.kinds()[0], - })!; - - expect(projectConfigImportRegistry.sources()).toEqual([productionSource]); - expect(projectConfigImportRegistry.get(productionSource.kind)).toBeTruthy(); - expect(() => projectConfigImportRegistry.assertProtocolCoverage()).not.toThrow(); - }); - - test("rejects duplicate adapters before coordinators run", () => { - expect(() => - createProjectConfigImportRegistry([fakeAdapter, fakeAdapter], fakeSourceSet), - ).toThrow("Duplicate project config import adapter: fake-source"); - }); - - test("rejects adapter kinds outside the protocol source union", () => { - expect(() => - createProjectConfigImportRegistry( - [ - { - source: { kind: "not-in-protocol" }, - inspect: () => null, - }, - ], - productionProjectConfigImportSourceSet, - ), - ).toThrow("Unknown project config import adapter: not-in-protocol"); - }); - - test("accepts explicit test source sets without protocol casts", () => { - const registry = createProjectConfigImportRegistry([fakeAdapter], fakeSourceSet); - - expect(registry.sources()).toEqual([{ kind: "fake-source", profile: "alpha" }]); - expect(registry.get("fake-source")).toBe(fakeAdapter); - expect(() => registry.assertProtocolCoverage()).not.toThrow(); - }); - - test("validates full adapter source descriptors", () => { - expect(() => - createProjectConfigImportRegistry( - [ - { - source: { kind: "fake-source" }, - inspect: () => null, - }, - ], - fakeSourceSet, - ), - ).toThrow("Unknown project config import adapter: fake-source"); - }); -}); diff --git a/packages/server/src/server/session/project-config/import/registry.ts b/packages/server/src/server/session/project-config/import/registry.ts deleted file mode 100644 index c55ab0fb6..000000000 --- a/packages/server/src/server/session/project-config/import/registry.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages"; -import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages"; -import type { ProjectConfigImportCandidate } from "./service.js"; -import { conductorProjectConfigImporter } from "./sources/conductor/importer.js"; - -const PROJECT_CONFIG_IMPORT_ADAPTERS = [conductorProjectConfigImporter]; - -export interface ProjectConfigImportAdapter< - TSource extends { kind: string } = ProjectConfigImportSource, -> { - readonly source: { kind: string; [key: string]: unknown }; - inspect(input: { repoRoot: string; source: TSource }): ProjectConfigImportCandidate | null; -} - -export interface ProjectConfigImportSourceSet { - parse(source: { kind: string; [key: string]: unknown }): TSource | null; - kinds(): readonly TSource["kind"][]; -} - -export interface ProjectConfigImportRegistry< - TSource extends { kind: string } = ProjectConfigImportSource, -> { - get(kind: TSource["kind"]): ProjectConfigImportAdapter | null; - sources(): TSource[]; - assertProtocolCoverage(): void; -} - -export function createProjectConfigImportRegistry( - adapters: readonly ProjectConfigImportAdapter[], - sourceSet: ProjectConfigImportSourceSet, -) { - const byKind = new Map>(); - for (const adapter of adapters) { - const source = sourceSet.parse(adapter.source); - if (!source) { - throw new Error(`Unknown project config import adapter: ${adapter.source.kind}`); - } - const kind = source.kind; - if (byKind.has(kind)) { - throw new Error(`Duplicate project config import adapter: ${adapter.source.kind}`); - } - byKind.set(kind, adapter); - } - - return { - get(kind: TSource["kind"]): ProjectConfigImportAdapter | null { - return byKind.get(kind) ?? null; - }, - sources(): TSource[] { - return Array.from(byKind.values()).map((adapter) => sourceSet.parse(adapter.source)!); - }, - assertProtocolCoverage(): void { - const missing = sourceSet.kinds().filter((kind) => !byKind.has(kind)); - if (missing.length > 0) { - throw new Error(`Missing project config import adapters: ${missing.join(", ")}`); - } - }, - } satisfies ProjectConfigImportRegistry; -} - -export const productionProjectConfigImportSourceSet = { - parse(source: { kind: string; [key: string]: unknown }): ProjectConfigImportSource | null { - const parsed = ProjectConfigImportSourceSchema.safeParse(source); - return parsed.success ? parsed.data : null; - }, - kinds: readProtocolSourceKinds, -} satisfies ProjectConfigImportSourceSet; - -export const projectConfigImportRegistry = createProjectConfigImportRegistry( - PROJECT_CONFIG_IMPORT_ADAPTERS, - productionProjectConfigImportSourceSet, -); - -function readProtocolSourceKinds(): ProjectConfigImportSource["kind"][] { - const options = ProjectConfigImportSourceSchema.options; - return options.map((option) => option.shape.kind.value); -} diff --git a/packages/server/src/server/session/project-config/import/service.test.ts b/packages/server/src/server/session/project-config/import/service.test.ts deleted file mode 100644 index cd165459d..000000000 --- a/packages/server/src/server/session/project-config/import/service.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { - createProjectConfigImportRegistry, - productionProjectConfigImportSourceSet, - type ProjectConfigImportAdapter, -} from "./registry.js"; -import { createProjectConfigImportService, type ProjectConfigImportCandidate } from "./service.js"; - -const tempDirs: string[] = []; -const PROTOCOL_SOURCE = productionProjectConfigImportSourceSet.parse({ - kind: productionProjectConfigImportSourceSet.kinds()[0], -})!; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function makeRepo(): string { - const repo = mkdtempSync(join(tmpdir(), "project-config-import-service-test-")); - tempDirs.push(repo); - return repo; -} - -function createFakeService(candidate: () => ProjectConfigImportCandidate | null) { - const adapter = { - source: PROTOCOL_SOURCE, - inspect: () => candidateFor(candidate()), - } satisfies ProjectConfigImportAdapter; - return createProjectConfigImportService( - createProjectConfigImportRegistry([adapter], productionProjectConfigImportSourceSet), - ); -} - -function candidateFor( - candidate: ProjectConfigImportCandidate | null, -): ProjectConfigImportCandidate | null { - if (!candidate) { - return null; - } - return { - ...candidate, - inputs: [{ role: "shared", relativePath: "source/config.json" }], - }; -} - -function baseCandidate(sourceRevision = "source-1"): ProjectConfigImportCandidate { - return { - sourceRevision, - inputs: [{ role: "shared", relativePath: "source/config.json" }], - items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }], - patch: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } }, - }; -} - -describe("project config import service", () => { - test("previews missing, available, and collision-only imports through an injected adapter", () => { - const repo = makeRepo(); - expect( - createFakeService(() => null).inspect({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - paseoConfig: {}, - paseoRevision: null, - }), - ).toMatchObject({ status: "not_found", preview: null, sourceRevision: null }); - - const service = createFakeService(() => baseCandidate()); - expect( - service.inspect({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - paseoConfig: {}, - paseoRevision: null, - }), - ).toMatchObject({ - status: "available", - preview: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } }, - }); - expect( - service.inspect({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - paseoConfig: { - worktree: { setup: "pnpm install" }, - scripts: { dev: { command: "pnpm dev" } }, - }, - paseoRevision: null, - }), - ).toMatchObject({ - status: "nothing_to_import", - preview: null, - items: [ - expect.objectContaining({ key: "worktree.setup", outcome: "collision" }), - expect.objectContaining({ key: "scripts.dev", outcome: "collision" }), - ], - }); - }); - - test("apply recomputes from disk and writes formatted paseo.json", () => { - const repo = makeRepo(); - let revision = "source-1"; - const service = createFakeService(() => baseCandidate(revision)); - - const result = service.apply({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - expectedSourceRevision: "source-1", - expectedPaseoRevision: null, - }); - - expect(result).toMatchObject({ - ok: true, - repoRoot: repo, - config: { worktree: { setup: "npm ci" } }, - }); - expect(readFileSync(join(repo, "paseo.json"), "utf8")).toContain('"setup": "npm ci"'); - - revision = "source-2"; - expect( - service.apply({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - expectedSourceRevision: "source-1", - expectedPaseoRevision: null, - }), - ).toEqual({ - ok: false, - repoRoot: repo, - error: { code: "stale_source_config", source: PROTOCOL_SOURCE }, - }); - }); - - test("apply rejects stale paseo.json revision after recomputing the source", () => { - const repo = makeRepo(); - writeFileSync(join(repo, "paseo.json"), '{"custom":true}\n'); - const service = createFakeService(() => baseCandidate()); - - expect( - service.apply({ - repoRoot: repo, - source: PROTOCOL_SOURCE, - expectedSourceRevision: "source-1", - expectedPaseoRevision: { mtimeMs: 1, size: 1 }, - }), - ).toMatchObject({ - ok: false, - repoRoot: repo, - error: { code: "stale_project_config" }, - }); - }); -}); diff --git a/packages/server/src/server/session/project-config/import/service.ts b/packages/server/src/server/session/project-config/import/service.ts deleted file mode 100644 index aef67d8c1..000000000 --- a/packages/server/src/server/session/project-config/import/service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - readPaseoConfigForEdit, - writePaseoConfigForEdit, -} from "../../../../utils/paseo-config-file.js"; -import { mergeProjectConfigImport } from "./merge.js"; -import type { ProjectConfigImportRegistry } from "./registry.js"; -import type { - PaseoConfigRaw, - PaseoConfigRevision, - ProjectConfigImportInput, - ProjectConfigImportItem, - ProjectConfigImportPreview, - ProjectConfigImportSource, - ProjectConfigRpcError, -} from "@getpaseo/protocol/messages"; - -export type { - ProjectConfigImportInput, - ProjectConfigImportItem, - ProjectConfigImportPreview, - ProjectConfigImportSource, -}; - -export interface ProjectConfigImportCandidate { - sourceRevision: string; - inputs: ProjectConfigImportInput[]; - items: ProjectConfigImportItem[]; - patch: PaseoConfigRaw; -} - -interface InspectProjectConfigImportInput { - repoRoot: string; - source: ProjectConfigImportSource; - paseoConfig: PaseoConfigRaw; - paseoRevision: PaseoConfigRevision | null; -} - -interface ApplyProjectConfigImportInput { - repoRoot: string; - source: ProjectConfigImportSource; - expectedSourceRevision: string; - expectedPaseoRevision: PaseoConfigRevision | null; -} - -type ProjectConfigImportApplyResult = - | { - ok: true; - repoRoot: string; - source: ProjectConfigImportSource; - config: PaseoConfigRaw; - revision: PaseoConfigRevision; - items: ProjectConfigImportItem[]; - } - | { ok: false; repoRoot: string; error: ProjectConfigRpcError }; - -export class InvalidProjectConfigImportSourceError extends Error { - readonly source: ProjectConfigImportSource; - readonly relativePath: string; - - constructor(source: ProjectConfigImportSource, relativePath: string) { - super(`Invalid ${source.kind} config at ${relativePath}`); - this.source = source; - this.relativePath = relativePath; - } -} - -export interface ProjectConfigImportService { - inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview; - apply(input: ApplyProjectConfigImportInput): ProjectConfigImportApplyResult; -} - -export function createProjectConfigImportService( - registry: ProjectConfigImportRegistry, -): ProjectConfigImportService { - function inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview { - const adapter = registry.get(input.source.kind); - const candidate: ProjectConfigImportCandidate | null = adapter - ? adapter.inspect({ repoRoot: input.repoRoot, source: input.source }) - : null; - - return mergeProjectConfigImport({ - repoRoot: input.repoRoot, - source: input.source, - candidate, - paseoConfig: input.paseoConfig, - paseoRevision: input.paseoRevision, - }); - } - - function apply(input: ApplyProjectConfigImportInput): ProjectConfigImportApplyResult { - const currentConfig = readPaseoConfigForEdit(input.repoRoot); - if (!currentConfig.ok) { - return { ok: false, repoRoot: input.repoRoot, error: currentConfig.error }; - } - - let preview: ProjectConfigImportPreview; - try { - preview = inspect({ - repoRoot: input.repoRoot, - source: input.source, - paseoConfig: currentConfig.config ?? {}, - paseoRevision: currentConfig.revision, - }); - } catch (error) { - if (error instanceof InvalidProjectConfigImportSourceError) { - return { - ok: false, - repoRoot: input.repoRoot, - error: { - code: "invalid_source_config", - source: error.source, - relativePath: error.relativePath, - }, - }; - } - throw error; - } - - if (preview.status === "not_found" || !preview.sourceRevision) { - return { - ok: false, - repoRoot: input.repoRoot, - error: { code: "source_config_not_found", source: input.source }, - }; - } - if (preview.sourceRevision !== input.expectedSourceRevision) { - return { - ok: false, - repoRoot: input.repoRoot, - error: { code: "stale_source_config", source: input.source }, - }; - } - if (!paseoConfigRevisionsEqual(currentConfig.revision, input.expectedPaseoRevision)) { - return { - ok: false, - repoRoot: input.repoRoot, - error: { code: "stale_project_config", currentRevision: currentConfig.revision }, - }; - } - if (preview.status === "nothing_to_import" || !preview.preview) { - return { ok: false, repoRoot: input.repoRoot, error: { code: "nothing_to_import" } }; - } - - const written = writePaseoConfigForEdit({ - repoRoot: input.repoRoot, - config: preview.preview, - expectedRevision: input.expectedPaseoRevision, - }); - if (!written.ok) { - return { ok: false, repoRoot: input.repoRoot, error: written.error }; - } - return { - ok: true, - repoRoot: input.repoRoot, - source: input.source, - config: written.config, - revision: written.revision, - items: preview.items, - }; - } - - return { inspect, apply }; -} - -function paseoConfigRevisionsEqual( - left: ProjectConfigImportPreview["paseoRevision"], - right: ProjectConfigImportPreview["paseoRevision"], -): boolean { - if (left === null || right === null) { - return left === right; - } - return left.mtimeMs === right.mtimeMs && left.size === right.size; -} diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts deleted file mode 100644 index 17ad6e9b3..000000000 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts +++ /dev/null @@ -1,772 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, test } from "vitest"; -import { - createProjectConfigImportRegistry, - productionProjectConfigImportSourceSet, -} from "../../registry.js"; -import { - createProjectConfigImportService, - InvalidProjectConfigImportSourceError, -} from "../../service.js"; -import { conductorProjectConfigImporter } from "./importer.js"; - -const tempDirs: string[] = []; -const CONDUCTOR_SOURCE = { kind: "conductor" } as const; -const service = createProjectConfigImportService( - createProjectConfigImportRegistry( - [conductorProjectConfigImporter], - productionProjectConfigImportSourceSet, - ), -); - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function makeRepo(): string { - const repo = mkdtempSync(join(tmpdir(), "conductor-import-test-")); - tempDirs.push(repo); - return repo; -} - -function writeSharedToml(repo: string, contents: string): void { - mkdirSync(join(repo, ".conductor"), { recursive: true }); - writeFileSync(join(repo, ".conductor", "settings.toml"), contents); -} - -function writeLocalToml(repo: string, contents: string): void { - mkdirSync(join(repo, ".conductor"), { recursive: true }); - writeFileSync(join(repo, ".conductor", "settings.local.toml"), contents); -} - -function writeSharedJson(repo: string, value: unknown): void { - mkdirSync(join(repo, ".conductor"), { recursive: true }); - writeFileSync(join(repo, ".conductor", "settings.json"), JSON.stringify(value)); -} - -function writeLocalJson(repo: string, value: unknown): void { - mkdirSync(join(repo, ".conductor"), { recursive: true }); - writeFileSync(join(repo, ".conductor", "settings.local.json"), JSON.stringify(value)); -} - -function inspect(repo: string, paseoConfig = {}) { - return service.inspect({ - repoRoot: repo, - source: CONDUCTOR_SOURCE, - paseoConfig, - paseoRevision: null, - }); -} - -function captureInvalidSourceError(repo: string): InvalidProjectConfigImportSourceError { - try { - inspect(repo); - } catch (error) { - if (error instanceof InvalidProjectConfigImportSourceError) { - return error; - } - } - throw new Error("Expected invalid source config error"); -} - -describe("Conductor project config import", () => { - test("maps shared TOML setup, archive, run service, cwd, args, rewrites, and unsupported settings", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -file_include_globs = ["config/*.local"] - -[scripts] -setup = "echo $CONDUCTOR_WORKSPACE_PATH && echo \${CONDUCTOR_ROOT_PATH}" -archive = "cleanup $CONDUCTOR_PORT" -run_mode = "nonconcurrent" -auto_run_after_setup = true - -[scripts.run.dev] -command = "npm run dev -- --port $CONDUCTOR_PORT" -args = ["--host", "0.0.0.0"] - -[scripts.run.dev.options] -cwd = "apps/web" - -[environment_variables] -SECRET_TOKEN = "do-not-return" - -[spotlight_testing] -enabled = true -`, - ); - - const preview = inspect(repo); - - expect(preview.status).toBe("available"); - expect(preview.inputs).toEqual([{ role: "shared", relativePath: ".conductor/settings.toml" }]); - expect(preview.preview).toMatchObject({ - worktree: { - setup: "echo $PASEO_WORKTREE_PATH && echo ${PASEO_SOURCE_CHECKOUT_PATH}", - teardown: "cleanup $PASEO_WORKTREE_PORT", - }, - scripts: { - dev: { - type: "service", - command: "cd -- 'apps/web' && npm run dev -- --port $PASEO_PORT '--host' '0.0.0.0'", - }, - }, - }); - expect(preview.preview?.scripts?.dev).not.toHaveProperty("port"); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "worktree.setup", outcome: "import" }), - expect.objectContaining({ key: "worktree.teardown", outcome: "import" }), - expect.objectContaining({ key: "scripts.dev", outcome: "import" }), - expect.objectContaining({ key: "variables.CONDUCTOR_WORKSPACE_PATH", outcome: "rewrite" }), - expect.objectContaining({ key: "variables.CONDUCTOR_ROOT_PATH", outcome: "rewrite" }), - expect.objectContaining({ key: "variables.CONDUCTOR_PORT", outcome: "rewrite" }), - expect.objectContaining({ key: "scripts.run_mode", outcome: "unsupported" }), - expect.objectContaining({ key: "scripts.auto_run_after_setup", outcome: "unsupported" }), - expect.objectContaining({ key: "file_include_globs", outcome: "unsupported" }), - expect.objectContaining({ - key: "environment_variables", - outcome: "unsupported", - detail: "Environment variable values are not imported. Found: SECRET_TOKEN.", - }), - expect.objectContaining({ key: "spotlight_testing", outcome: "unsupported" }), - ]), - ); - expect(JSON.stringify(preview.items)).not.toContain("do-not-return"); - }); - - test("merges legacy scoped JSON before TOML and lets local TOML win", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts] -setup = "shared setup" -[scripts.run.dev] -command = "shared dev" -`, - ); - writeLocalToml( - repo, - ` -[scripts] -setup = "local setup" -[scripts.run.dev] -command = "local dev" -`, - ); - writeSharedJson(repo, { - scripts: { setup: "scoped legacy shared setup", archive: "scoped legacy archive" }, - }); - writeLocalJson(repo, { scripts: { setup: "scoped legacy local setup" } }); - writeFileSync( - join(repo, "conductor.json"), - JSON.stringify({ scripts: { setup: "legacy setup", run: "legacy run" } }), - ); - - const preview = inspect(repo); - - expect(preview.inputs).toEqual([ - { role: "shared", relativePath: ".conductor/settings.json" }, - { role: "shared", relativePath: ".conductor/settings.toml" }, - { role: "local", relativePath: ".conductor/settings.local.json" }, - { role: "local", relativePath: ".conductor/settings.local.toml" }, - ]); - expect(preview.preview).toMatchObject({ - worktree: { setup: "local setup", teardown: "scoped legacy archive" }, - scripts: { dev: { command: "local dev" } }, - }); - }); - - test("deep-merges local run-script fields with shared commands", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev" -args = ["--shared"] -[scripts.run.dev.options] -cwd = "apps/web" -`, - ); - writeLocalToml( - repo, - ` -[scripts.run.dev] -args = ["--local"] -[scripts.run.dev.options] -cwd = "apps/local-web" -`, - ); - - expect(inspect(repo).preview).toMatchObject({ - scripts: { - dev: { command: "cd -- 'apps/local-web' && npm run dev '--local'" }, - }, - }); - }); - - test("imports legacy scoped JSON settings with local overrides", () => { - const repo = makeRepo(); - writeSharedJson(repo, { - scripts: { setup: "shared setup", run: { dev: { command: "npm run dev" } } }, - }); - writeLocalJson(repo, { scripts: { run: { dev: { args: ["--local"] } } } }); - - const preview = inspect(repo); - - expect(preview.inputs).toEqual([ - { role: "shared", relativePath: ".conductor/settings.json" }, - { role: "local", relativePath: ".conductor/settings.local.json" }, - ]); - expect(preview.preview).toMatchObject({ - worktree: { setup: "shared setup" }, - scripts: { dev: { command: "npm run dev '--local'" } }, - }); - }); - - test("imports legacy conductor.json when shared TOML is absent", () => { - const repo = makeRepo(); - writeFileSync( - join(repo, "conductor.json"), - JSON.stringify({ scripts: { setup: "legacy setup", run: "npm test" } }), - ); - - const preview = inspect(repo); - - expect(preview.inputs).toEqual([{ role: "legacy", relativePath: "conductor.json" }]); - expect(preview.preview).toMatchObject({ - worktree: { setup: "legacy setup" }, - scripts: { run: { command: "npm test" } }, - }); - }); - - test("merges root conductor.json before scoped JSON until TOML migration", () => { - const repo = makeRepo(); - writeFileSync( - join(repo, "conductor.json"), - JSON.stringify({ scripts: { setup: "legacy setup" }, runScriptMode: "nonconcurrent" }), - ); - writeSharedJson(repo, { scripts: { run: { dev: { command: "npm run dev" } } } }); - - const preview = inspect(repo); - - expect(preview.inputs).toEqual([ - { role: "legacy", relativePath: "conductor.json" }, - { role: "shared", relativePath: ".conductor/settings.json" }, - ]); - expect(preview.preview).toMatchObject({ - worktree: { setup: "legacy setup" }, - scripts: { dev: { command: "npm run dev" } }, - }); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "runScriptMode", outcome: "unsupported" }), - ]), - ); - }); - - test("reports missing and empty repository-local Conductor configs", () => { - const missingRepo = makeRepo(); - const emptyRepo = makeRepo(); - writeSharedToml(emptyRepo, ""); - - expect(inspect(missingRepo)).toEqual({ - repoRoot: missingRepo, - source: CONDUCTOR_SOURCE, - status: "not_found", - sourceRevision: null, - paseoRevision: null, - inputs: [], - items: [], - preview: null, - }); - expect(inspect(emptyRepo)).toMatchObject({ - repoRoot: emptyRepo, - source: CONDUCTOR_SOURCE, - status: "nothing_to_import", - inputs: [{ role: "shared", relativePath: ".conductor/settings.toml" }], - items: [], - preview: null, - }); - }); - - test("does not rewrite variable substrings and warns for unsupported Conductor variables", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts] -setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH" -`, - ); - - const preview = inspect(repo); - - expect(preview.preview).toMatchObject({ - worktree: { setup: "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH" }, - }); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "variables.CONDUCTOR_DEFAULT_BRANCH", - outcome: "unsupported", - }), - ]), - ); - expect(preview.items).not.toEqual( - expect.arrayContaining([expect.objectContaining({ key: "variables.CONDUCTOR_PORT" })]), - ); - }); - - test("preserves shell expansion for environment variables in script arguments", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev" -args = ["--port", "$CONDUCTOR_PORT", "--label=$WORKSPACE_NAME"] -`, - ); - - expect(inspect(repo).preview).toMatchObject({ - scripts: { - dev: { - type: "service", - command: `npm run dev '--port' "$PASEO_PORT" '--label='"$WORKSPACE_NAME"`, - }, - }, - }); - }); - - test("preserves shell parameter expansion in script arguments", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev" -args = ["--port=\${CONDUCTOR_PORT:-3000}"] -`, - ); - - expect(inspect(repo).preview).toMatchObject({ - scripts: { - dev: { - type: "service", - command: `npm run dev '--port='"\${PASEO_PORT:-3000}"`, - }, - }, - }); - }); - - test("rejects normalized working directories that escape the project root", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.parent] -command = "npm test" -[scripts.run.parent.options] -cwd = "./.." - -[scripts.run.nested] -command = "npm test" -[scripts.run.nested.options] -cwd = "apps/web/../../.." - -[scripts.run.unc] -command = "npm test" -[scripts.run.unc.options] -cwd = '\\\\server\\share' -`, - ); - - const preview = inspect(repo); - - expect(preview.preview).toMatchObject({ - scripts: { - parent: { command: "npm test" }, - nested: { command: "npm test" }, - unc: { command: "npm test" }, - }, - }); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "scripts.parent.cwd", outcome: "unsupported" }), - expect.objectContaining({ key: "scripts.nested.cwd", outcome: "unsupported" }), - expect.objectContaining({ key: "scripts.unc.cwd", outcome: "unsupported" }), - ]), - ); - }); - - test("emits normalized relative working directories", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm test" -[scripts.run.dev.options] -cwd = 'apps\\web' -`, - ); - - expect(inspect(repo).preview).toMatchObject({ - scripts: { dev: { command: "cd -- 'apps/web' && npm test" } }, - }); - }); - - test("rewrites Conductor ports inside shell parameter expansions", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev -- --port \${CONDUCTOR_PORT:-3000}" -`, - ); - - expect(inspect(repo).preview).toMatchObject({ - scripts: { - dev: { - type: "service", - command: "npm run dev -- --port ${PASEO_PORT:-3000}", - }, - }, - }); - }); - - test("reports Conductor port arithmetic as unsupported instead of importing it", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev -- --hmr-port $((CONDUCTOR_PORT + 1))" - -[scripts.run.args] -command = "npm run dev" -args = ["--hmr-port=$((CONDUCTOR_PORT + 1))"] - -[scripts.run.plain] -command = "npm run dev -- --port $((CONDUCTOR_PORT))" -`, - ); - - const preview = inspect(repo); - - expect(preview.preview).toEqual({ - scripts: { - plain: { - command: "npm run dev -- --port $((PASEO_PORT))", - type: "service", - }, - }, - }); - expect(preview.items).toEqual([ - { - key: "scripts.args.port_arithmetic", - label: "Script args port arithmetic", - outcome: "unsupported", - detail: - "Conductor port arithmetic is not imported because Paseo reserves one service port.", - }, - { - key: "scripts.dev.port_arithmetic", - label: "Script dev port arithmetic", - outcome: "unsupported", - detail: - "Conductor port arithmetic is not imported because Paseo reserves one service port.", - }, - { - key: "variables.CONDUCTOR_PORT", - label: "CONDUCTOR_PORT", - outcome: "rewrite", - detail: "CONDUCTOR_PORT -> PASEO_PORT", - }, - { - key: "scripts.plain", - label: "Script plain", - outcome: "import", - detail: "npm run dev -- --port $((PASEO_PORT))", - }, - ]); - }); - - test("does not import scripts available only in Conductor cloud", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.cloud] -command = "npm run cloud" -available_in = ["cloud"] - -[scripts.run.everywhere] -command = "npm run everywhere" -available_in = ["local", "cloud"] -`, - ); - - const preview = inspect(repo); - - expect(preview.preview).toMatchObject({ - scripts: { everywhere: { command: "npm run everywhere" } }, - }); - expect(preview.preview?.scripts).not.toHaveProperty("cloud"); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "scripts.cloud", - outcome: "unsupported", - detail: "Cloud-only scripts are not imported.", - }), - ]), - ); - }); - - test("does not import hidden Conductor run scripts", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.helper] -command = "npm run helper" -hide = true -`, - ); - - const preview = inspect(repo); - - expect(preview.preview?.scripts ?? {}).not.toHaveProperty("helper"); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "scripts.helper", - outcome: "unsupported", - detail: "Hidden scripts are not imported.", - }), - ]), - ); - }); - - test("reports nested environment variables, prompts, and Git settings", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[environment_variables.local] -LOCAL_TOKEN = "local-secret" - -[environment_variables.cloud] -CLOUD_TOKEN = "cloud-secret" - -[prompts] -system = "custom prompt" - -[git] -default_branch = "develop" -`, - ); - - expect(inspect(repo).items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "environment_variables", - outcome: "unsupported", - detail: "Environment variable values are not imported. Found: CLOUD_TOKEN, LOCAL_TOKEN.", - }), - expect.objectContaining({ key: "prompts", outcome: "unsupported" }), - expect.objectContaining({ key: "git", outcome: "unsupported" }), - ]), - ); - }); - - test("preserves shared environment variable names under local scoped overrides", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[environment_variables.local] -SHARED_TOKEN = "shared-secret" -`, - ); - writeLocalToml( - repo, - ` -[environment_variables.cloud] -CLOUD_TOKEN = "cloud-secret" -`, - ); - - expect(inspect(repo).items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "environment_variables", - detail: "Environment variable values are not imported. Found: CLOUD_TOKEN, SHARED_TOKEN.", - }), - ]), - ); - }); - - test("reports legacy run mode, privacy, and harness settings", () => { - const repo = makeRepo(); - writeFileSync( - join(repo, "conductor.json"), - JSON.stringify({ - scripts: { setup: "npm ci" }, - runScriptMode: "nonconcurrent", - enterpriseDataPrivacy: true, - claude_code_executable_path: "/opt/claude", - codex_executable_path: "/opt/codex", - claude_provider: "bedrock", - codex_provider: "custom", - bedrock_region: "eu-west-1", - vertex_project_id: "project", - ssh_key_path: "~/.ssh/id_ed25519", - }), - ); - - expect(inspect(repo).items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "runScriptMode", outcome: "unsupported" }), - expect.objectContaining({ key: "enterpriseDataPrivacy", outcome: "unsupported" }), - expect.objectContaining({ key: "claude_code_executable_path", outcome: "unsupported" }), - expect.objectContaining({ key: "codex_executable_path", outcome: "unsupported" }), - expect.objectContaining({ key: "claude_provider", outcome: "unsupported" }), - expect.objectContaining({ key: "codex_provider", outcome: "unsupported" }), - expect.objectContaining({ key: "bedrock_region", outcome: "unsupported" }), - expect.objectContaining({ key: "vertex_project_id", outcome: "unsupported" }), - expect.objectContaining({ key: "ssh_key_path", outcome: "unsupported" }), - ]), - ); - }); - - test("reports snake-case enterprise data privacy settings", () => { - const repo = makeRepo(); - writeSharedToml(repo, "enterprise_data_privacy = true\n"); - - expect(inspect(repo).items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "enterprise_data_privacy", outcome: "unsupported" }), - ]), - ); - }); - - test("reports default and icon fields on imported run scripts", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.dev] -command = "npm run dev" -default = true -icon = "play" -`, - ); - - const preview = inspect(repo); - - expect(preview.preview).toMatchObject({ scripts: { dev: { command: "npm run dev" } } }); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "scripts.dev.default", outcome: "unsupported" }), - expect.objectContaining({ key: "scripts.dev.icon", outcome: "unsupported" }), - ]), - ); - }); - - test("does not import services with colliding normalized environment names", () => { - const repo = makeRepo(); - writeSharedToml( - repo, - ` -[scripts.run.app-server] -command = "npm run app -- --port $CONDUCTOR_PORT" - -[scripts.run."app.server"] -command = "npm run other -- --port $CONDUCTOR_PORT" -`, - ); - - const preview = inspect(repo); - - expect(preview.preview?.scripts).toMatchObject({ - "app-server": { type: "service" }, - }); - expect(preview.preview?.scripts).not.toHaveProperty("app.server"); - expect(preview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "scripts.app.server", - outcome: "collision", - detail: 'Service environment name collides with "app-server" (APP_SERVER).', - }), - ]), - ); - }); - - test("malformed TOML identifies the safe relative source path", () => { - const repo = makeRepo(); - writeSharedToml(repo, "[scripts\nsetup = nope"); - - expect(() => inspect(repo)).toThrow(InvalidProjectConfigImportSourceError); - expect(captureInvalidSourceError(repo)).toMatchObject({ - source: CONDUCTOR_SOURCE, - relativePath: ".conductor/settings.toml", - }); - }); - - test("malformed local TOML identifies the local override path", () => { - const repo = makeRepo(); - writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); - writeLocalToml(repo, "[scripts\nsetup = nope"); - - expect(captureInvalidSourceError(repo)).toMatchObject({ - source: CONDUCTOR_SOURCE, - relativePath: ".conductor/settings.local.toml", - }); - }); - - test("unreadable source paths identify the invalid relative file", () => { - const repo = makeRepo(); - mkdirSync(join(repo, ".conductor", "settings.toml"), { recursive: true }); - - expect(captureInvalidSourceError(repo)).toMatchObject({ - source: CONDUCTOR_SOURCE, - relativePath: ".conductor/settings.toml", - }); - }); - - test(".worktreeinclude is reported and not converted to shell commands", () => { - const repo = makeRepo(); - writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n'); - writeFileSync(join(repo, ".worktreeinclude"), "config/*.local\n"); - - const initialPreview = inspect(repo); - expect(initialPreview.inputs).toEqual([ - { role: "shared", relativePath: ".conductor/settings.toml" }, - { role: "include", relativePath: ".worktreeinclude" }, - ]); - expect(initialPreview.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: ".worktreeinclude", - outcome: "unsupported", - detail: "Worktree include patterns are not converted to shell copy commands.", - }), - ]), - ); - writeFileSync(join(repo, ".worktreeinclude"), "config/*.local\nsecrets/*.local\n"); - expect(inspect(repo).sourceRevision).not.toBe(initialPreview.sourceRevision); - }); -}); diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts deleted file mode 100644 index cbf90963c..000000000 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { join, posix, relative } from "node:path"; -import { parse as parseToml } from "smol-toml"; -import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages"; -import type { ProjectConfigImportAdapter } from "../../registry.js"; -import { - InvalidProjectConfigImportSourceError, - type ProjectConfigImportCandidate, - type ProjectConfigImportInput, - type ProjectConfigImportItem, - type ProjectConfigImportSource, -} from "../../service.js"; - -interface SourceFile { - role: string; - relativePath: string; - path: string; - bytes: string; - containsSettings: boolean; -} - -interface ConductorSettings { - scripts?: { - setup?: unknown; - archive?: unknown; - run?: unknown; - run_mode?: unknown; - auto_run_after_setup?: unknown; - }; - file_include_globs?: unknown; - environment_variables?: unknown; - environment_variables_forward?: unknown; - runScriptMode?: unknown; - enterprise_data_privacy?: unknown; - enterpriseDataPrivacy?: unknown; - prompts?: unknown; - git?: unknown; - spotlight_testing?: unknown; - [key: string]: unknown; -} - -interface ConductorRunScript { - command: string; - args?: string[]; - default?: boolean; - hide?: boolean; - icon?: string; - options?: { - cwd?: string; - }; - available_in?: string | string[]; -} - -type RewriteContext = "lifecycle" | "run"; - -export const conductorProjectConfigImporter = { - source: { kind: "conductor" }, - inspect: inspectConductorImport, -} satisfies ProjectConfigImportAdapter<{ kind: "conductor" }>; - -function inspectConductorImport(input: { - repoRoot: string; - source: ProjectConfigImportSource; -}): ProjectConfigImportCandidate | null { - const sourceFiles = discoverConductorSources(input.repoRoot, input.source); - if (sourceFiles.length === 0) { - return null; - } - - const settings = loadConductorSettings(input.source, sourceFiles); - const inputs = sourceFiles.map((file) => ({ - role: file.role, - relativePath: file.relativePath, - })); - const patch: PaseoConfigRaw = {}; - const items: ProjectConfigImportItem[] = []; - - mapLifecycle(settings.scripts?.setup, { - key: "worktree.setup", - label: "Worktree setup", - target: "setup", - patch, - items, - }); - mapLifecycle(settings.scripts?.archive, { - key: "worktree.teardown", - label: "Worktree teardown", - target: "teardown", - patch, - items, - }); - mapRunScripts(settings.scripts?.run, patch, items); - reportUnsupported(input.repoRoot, settings, items); - - return { - sourceRevision: hashSourceFiles(sourceFiles), - inputs, - items, - patch, - }; -} - -function discoverConductorSources( - repoRoot: string, - source: ProjectConfigImportSource, -): SourceFile[] { - const localTomlPath = join(repoRoot, ".conductor", "settings.local.toml"); - const localJsonPath = join(repoRoot, ".conductor", "settings.local.json"); - const sharedTomlPath = join(repoRoot, ".conductor", "settings.toml"); - const sharedJsonPath = join(repoRoot, ".conductor", "settings.json"); - const rootLegacyPath = join(repoRoot, "conductor.json"); - const worktreeIncludePath = join(repoRoot, ".worktreeinclude"); - const files: SourceFile[] = []; - - if (!existsSync(sharedTomlPath) && existsSync(rootLegacyPath)) { - files.push(readSourceFile(repoRoot, rootLegacyPath, "legacy", true, source)); - } - if (existsSync(sharedJsonPath)) { - files.push(readSourceFile(repoRoot, sharedJsonPath, "shared", true, source)); - } - if (existsSync(sharedTomlPath)) { - files.push(readSourceFile(repoRoot, sharedTomlPath, "shared", true, source)); - } - if (existsSync(localJsonPath)) { - files.push(readSourceFile(repoRoot, localJsonPath, "local", true, source)); - } - if (existsSync(localTomlPath)) { - files.push(readSourceFile(repoRoot, localTomlPath, "local", true, source)); - } - if (existsSync(worktreeIncludePath)) { - files.push(readSourceFile(repoRoot, worktreeIncludePath, "include", false, source)); - } - return files; -} - -function readSourceFile( - repoRoot: string, - path: string, - role: string, - containsSettings: boolean, - source: ProjectConfigImportSource, -): SourceFile { - const relativePath = relative(repoRoot, path).replaceAll("\\", "/"); - let bytes: string; - try { - bytes = readFileSync(path, "utf8"); - } catch { - throw new InvalidProjectConfigImportSourceError(source, relativePath); - } - return { - role, - relativePath, - path, - bytes, - containsSettings, - }; -} - -function loadConductorSettings( - source: ProjectConfigImportSource, - sourceFiles: SourceFile[], -): ConductorSettings { - let merged: ConductorSettings = {}; - for (const file of sourceFiles) { - if (!file.containsSettings) { - continue; - } - let parsed: unknown; - try { - parsed = file.relativePath.endsWith(".json") ? JSON.parse(file.bytes) : parseToml(file.bytes); - } catch { - throw new InvalidProjectConfigImportSourceError(source, file.relativePath); - } - if (!isRecord(parsed)) { - throw new InvalidProjectConfigImportSourceError(source, file.relativePath); - } - merged = mergeSettings(merged, parsed as ConductorSettings); - } - return merged; -} - -function mergeSettings(base: ConductorSettings, override: ConductorSettings): ConductorSettings { - return { - ...base, - ...override, - scripts: { - ...(isRecord(base.scripts) ? base.scripts : {}), - ...(isRecord(override.scripts) ? override.scripts : {}), - run: mergeRunScripts(base.scripts?.run, override.scripts?.run), - }, - environment_variables: mergeNestedSettings( - base.environment_variables, - override.environment_variables, - ), - environment_variables_forward: mergeNestedSettings( - base.environment_variables_forward, - override.environment_variables_forward, - ), - }; -} - -function mergeNestedSettings(base: unknown, override: unknown): unknown { - if (!isRecord(base) || !isRecord(override)) { - return override ?? base; - } - const merged: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - merged[key] = mergeNestedSettings(base[key], value); - } - return merged; -} - -function mergeRunScripts(base: unknown, override: unknown): unknown { - if (typeof override === "string") { - return override; - } - if (!isRecord(base) || !isRecord(override)) { - return override ?? base; - } - const merged: Record = { ...base }; - for (const [scriptId, overrideEntry] of Object.entries(override)) { - const baseEntry = base[scriptId]; - merged[scriptId] = mergeRunScriptEntry(baseEntry, overrideEntry); - } - return merged; -} - -function mergeRunScriptEntry(base: unknown, override: unknown): unknown { - if (!isRecord(base) || !isRecord(override)) { - return override ?? base; - } - const merged: Record = { ...base, ...override }; - if (isRecord(base.options) && isRecord(override.options)) { - merged.options = { ...base.options, ...override.options }; - } - return merged; -} - -function mapLifecycle( - value: unknown, - input: { - key: string; - label: string; - target: "setup" | "teardown"; - patch: PaseoConfigRaw; - items: ProjectConfigImportItem[]; - }, -): void { - if (typeof value !== "string" || value.trim().length === 0) { - return; - } - const rewritten = rewriteVariables(value, "lifecycle", input.items); - input.patch.worktree = { ...input.patch.worktree, [input.target]: rewritten.command }; - input.items.push({ - key: input.key, - label: input.label, - outcome: "import", - detail: rewritten.command, - }); -} - -function mapRunScripts( - runConfig: unknown, - patch: PaseoConfigRaw, - items: ProjectConfigImportItem[], -): void { - if (typeof runConfig === "string") { - mapRunScript("run", { command: runConfig }, patch, items); - return; - } - if (!isRecord(runConfig)) { - return; - } - for (const scriptId of Object.keys(runConfig).sort()) { - const entry = runConfig[scriptId]; - if (!isRecord(entry)) { - continue; - } - const command = entry.command; - if (typeof command !== "string" || command.trim().length === 0) { - continue; - } - mapRunScript(scriptId, normalizeRunScript(entry, command), patch, items); - } -} - -function normalizeRunScript(entry: Record, command: string): ConductorRunScript { - const args = Array.isArray(entry.args) - ? entry.args.filter((arg): arg is string => typeof arg === "string") - : undefined; - const options = isRecord(entry.options) ? entry.options : undefined; - const availableIn = normalizeAvailableIn(entry.available_in); - return { - command, - ...(args ? { args } : {}), - ...(typeof entry.default === "boolean" ? { default: entry.default } : {}), - ...(typeof entry.hide === "boolean" ? { hide: entry.hide } : {}), - ...(typeof entry.icon === "string" ? { icon: entry.icon } : {}), - ...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}), - ...(availableIn ? { available_in: availableIn } : {}), - }; -} - -function mapRunScript( - scriptId: string, - script: ConductorRunScript, - patch: PaseoConfigRaw, - items: ProjectConfigImportItem[], -): void { - if (script.hide) { - items.push({ - key: `scripts.${scriptId}`, - label: `Script ${scriptId}`, - outcome: "unsupported", - detail: "Hidden scripts are not imported.", - }); - return; - } - - if (isCloudOnly(script.available_in)) { - items.push({ - key: `scripts.${scriptId}`, - label: `Script ${scriptId}`, - outcome: "unsupported", - detail: "Cloud-only scripts are not imported.", - }); - return; - } - - if (script.default !== undefined) { - unsupported(items, `scripts.${scriptId}.default`, "Default script selection is not imported."); - } - if (script.icon !== undefined) { - unsupported(items, `scripts.${scriptId}.icon`, "Script icons are not imported."); - } - - let command = appendArgs(script.command, script.args ?? []); - if (containsArithmeticVariableOperation(command, "CONDUCTOR_PORT")) { - items.push({ - key: `scripts.${scriptId}.port_arithmetic`, - label: `Script ${scriptId} port arithmetic`, - outcome: "unsupported", - detail: "Conductor port arithmetic is not imported because Paseo reserves one service port.", - }); - return; - } - if (script.options?.cwd) { - const cwdPrefix = safeCwdPrefix(script.options.cwd); - if (!cwdPrefix) { - items.push({ - key: `scripts.${scriptId}.cwd`, - label: `Script ${scriptId} working directory`, - outcome: "unsupported", - detail: "Absolute or escaping cwd values are not imported.", - }); - } else { - command = `${cwdPrefix}${command}`; - } - } - - const isService = containsShellVariable(command, "CONDUCTOR_PORT"); - const rewritten = rewriteVariables(command, isService ? "run" : "lifecycle", items); - const entry: PaseoScriptEntryRaw = { command: rewritten.command }; - if (isService) { - entry.type = "service"; - } - - patch.scripts = { ...patch.scripts, [scriptId]: entry }; - items.push({ - key: `scripts.${scriptId}`, - label: `Script ${scriptId}`, - outcome: "import", - detail: rewritten.command, - }); -} - -function reportUnsupported( - repoRoot: string, - settings: ConductorSettings, - items: ProjectConfigImportItem[], -): void { - const scripts = settings.scripts; - if (scripts?.run_mode !== undefined) { - unsupported(items, "scripts.run_mode", "Paseo has no project-wide run mode."); - } - if (settings.runScriptMode !== undefined) { - unsupported(items, "runScriptMode", "Paseo has no project-wide run mode."); - } - if (scripts?.auto_run_after_setup !== undefined) { - unsupported( - items, - "scripts.auto_run_after_setup", - "Paseo does not auto-run scripts after setup.", - ); - } - if (settings.file_include_globs !== undefined) { - unsupported( - items, - "file_include_globs", - "File include globs are not converted to shell copy commands.", - ); - } - if (existsSync(join(repoRoot, ".worktreeinclude"))) { - unsupported( - items, - ".worktreeinclude", - "Worktree include patterns are not converted to shell copy commands.", - ); - } - const environmentNames = collectEnvironmentVariableNames(settings); - if (environmentNames.length > 0) { - unsupported( - items, - "environment_variables", - `Environment variable values are not imported. Found: ${environmentNames.join(", ")}.`, - ); - } - if (settings.spotlight_testing !== undefined) { - unsupported( - items, - "spotlight_testing", - "Paseo spotlight is a separate workflow, not project config.", - ); - } - if (settings.prompts !== undefined) { - unsupported(items, "prompts", "Custom agent prompts are not imported."); - } - if (settings.git !== undefined) { - unsupported(items, "git", "Conductor Git settings are not imported."); - } - for (const key of ["enterprise_data_privacy", "enterpriseDataPrivacy"] as const) { - if (settings[key] !== undefined) { - unsupported(items, key, "Conductor enterprise data privacy settings are not imported."); - } - } - for (const key of [ - "claude_code_executable_path", - "codex_executable_path", - "claude_provider", - "codex_provider", - "bedrock_region", - "vertex_project_id", - "ssh_key_path", - ] as const) { - if (settings[key] !== undefined) { - unsupported(items, key, "Conductor harness and provider settings are not imported."); - } - } -} - -function collectEnvironmentVariableNames(settings: ConductorSettings): string[] { - const names = new Set(); - for (const key of ["environment_variables", "environment_variables_forward"] as const) { - collectEnvironmentVariableNamesFromValue(settings[key], names); - } - return Array.from(names).sort(); -} - -function collectEnvironmentVariableNamesFromValue(value: unknown, names: Set): void { - if (Array.isArray(value)) { - for (const name of value) { - if (typeof name === "string") { - names.add(name); - } - } - return; - } - if (!isRecord(value)) { - return; - } - for (const [name, nestedValue] of Object.entries(value)) { - if (isRecord(nestedValue)) { - collectEnvironmentVariableNamesFromValue(nestedValue, names); - } else { - names.add(name); - } - } -} - -function unsupported(items: ProjectConfigImportItem[], key: string, detail: string): void { - items.push({ key, label: key, outcome: "unsupported", detail }); -} - -function rewriteVariables( - command: string, - context: RewriteContext, - items: ProjectConfigImportItem[], -): { command: string } { - const replacements = new Map([ - ["CONDUCTOR_WORKSPACE_PATH", "PASEO_WORKTREE_PATH"], - ["CONDUCTOR_ROOT_PATH", "PASEO_SOURCE_CHECKOUT_PATH"], - ["CONDUCTOR_PORT", context === "run" ? "PASEO_PORT" : "PASEO_WORKTREE_PORT"], - ]); - const unsupportedVariables = new Set([ - "CONDUCTOR_DEFAULT_BRANCH", - "CONDUCTOR_WORKSPACE_NAME", - "CONDUCTOR_IS_LOCAL", - ]); - let rewritten = command; - for (const [from, to] of replacements) { - const next = replaceShellVariable(rewritten, from, to); - if (next !== rewritten) { - items.push({ - key: `variables.${from}`, - label: from, - outcome: "rewrite", - detail: `${from} -> ${to}`, - }); - rewritten = next; - } - } - for (const name of unsupportedVariables) { - if (containsShellVariable(rewritten, name)) { - items.push({ - key: `variables.${name}`, - label: name, - outcome: "unsupported", - detail: `${name} has no equivalent Paseo variable.`, - }); - } - } - return { command: rewritten }; -} - -function replaceShellVariable(command: string, from: string, to: string): string { - const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g"); - return replaceArithmeticVariable( - command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`)), - from, - to, - ); -} - -function containsShellVariable(command: string, name: string): boolean { - const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`); - return pattern.test(command) || containsArithmeticVariable(command, name); -} - -function replaceArithmeticVariable(command: string, from: string, to: string): string { - return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string) => { - const identifier = new RegExp(`(^|[^A-Za-z0-9_])${from}(?![A-Za-z0-9_])`, "g"); - const rewrittenBody = body.replace(identifier, (_match, prefix: string) => `${prefix}${to}`); - return rewrittenBody === body ? expression : `$((` + rewrittenBody + `))`; - }); -} - -function containsArithmeticVariable(command: string, name: string): boolean { - const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); - for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { - if (identifier.test(match[1])) { - return true; - } - } - return false; -} - -function containsArithmeticVariableOperation(command: string, name: string): boolean { - const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); - for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { - const body = match[1].trim(); - if (identifier.test(body) && body !== name) { - return true; - } - } - return false; -} - -function appendArgs(command: string, args: string[]): string { - if (args.length === 0) { - return command; - } - return `${command} ${args.map(shellQuoteArgument).join(" ")}`; -} - -function safeCwdPrefix(cwd: string): string | null { - const normalized = posix.normalize(cwd.replaceAll("\\", "/")); - if ( - normalized.startsWith("/") || - /^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || - normalized === ".." || - normalized.startsWith("../") - ) { - return null; - } - return `cd -- ${shellQuote(normalized)} && `; -} - -function isCloudOnly(availableIn: string | string[] | undefined): boolean { - return ( - availableIn === "cloud" || - (Array.isArray(availableIn) && - availableIn.length > 0 && - availableIn.every((target) => target === "cloud")) - ); -} - -function normalizeAvailableIn(value: unknown): string | string[] | undefined { - if (typeof value === "string") { - return value; - } - if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === "string"); - } - return undefined; -} - -function shellQuoteArgument(value: string): string { - const variablePattern = - /\$\(\([\s\S]*?\)\)|\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?:(?:[^{}])|\{[^{}]*\})*\}|[A-Za-z_][A-Za-z0-9_]*)/g; - const parts: string[] = []; - let offset = 0; - for (const match of value.matchAll(variablePattern)) { - const index = match.index; - if (index > offset) { - parts.push(shellQuote(value.slice(offset, index))); - } - parts.push(`"${match[0]}"`); - offset = index + match[0].length; - } - if (offset < value.length) { - parts.push(shellQuote(value.slice(offset))); - } - return parts.length > 0 ? parts.join("") : shellQuote(value); -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function hashSourceFiles(sourceFiles: SourceFile[]): string { - const hash = createHash("sha256"); - for (const file of [...sourceFiles].sort((left, right) => - left.relativePath.localeCompare(right.relativePath), - )) { - hash.update(file.relativePath); - hash.update("\0"); - hash.update(file.bytes); - hash.update("\0"); - } - return hash.digest("hex"); -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 0d031e7a8..0faa95404 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -3,23 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import pino from "pino"; -import { - ProjectConfigImportSourceSchema, - type ProjectConfigImportSource, -} from "@getpaseo/protocol/messages"; import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js"; import type { PersistedProjectRecord } from "../../workspace-registry.js"; import type { SessionOutboundMessage } from "../../messages.js"; -import { - InvalidProjectConfigImportSourceError, - type ProjectConfigImportService, -} from "./import/service.js"; const tempDirs: string[] = []; -const PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE: ProjectConfigImportSource = - ProjectConfigImportSourceSchema.options[0].parse({ - kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value, - }); afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -45,16 +33,12 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers }; } -function makeSubsystem( - records: PersistedProjectRecord[], - importService?: ProjectConfigImportService, -) { +function makeSubsystem(records: PersistedProjectRecord[]) { const emitted: SessionOutboundMessage[] = []; const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) }; const subsystem = new ProjectConfigSession({ host, projectRegistry: { list: async () => records }, - ...(importService ? { importService } : {}), logger: pino({ level: "silent" }), }); return { subsystem, emitted }; @@ -243,167 +227,4 @@ describe("ProjectConfigSession", () => { }, ]); }); - - test("import preview rejects archived and unknown roots without touching the import service", async () => { - const archivedRoot = makeRoot(); - const unknownRoot = makeRoot(); - const serviceCalls: string[] = []; - const { subsystem, emitted } = makeSubsystem( - [projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")], - { - inspect: () => { - serviceCalls.push("inspect"); - throw new Error("unexpected import inspect"); - }, - apply: () => { - serviceCalls.push("apply"); - throw new Error("unexpected import apply"); - }, - }, - ); - - await subsystem.handleGetProjectConfigImportRequest({ - type: "project.config.get_import.request", - requestId: "import-archived-1", - repoRoot: archivedRoot, - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - await subsystem.handleGetProjectConfigImportRequest({ - type: "project.config.get_import.request", - requestId: "import-unknown-1", - repoRoot: unknownRoot, - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - - expect(emitted).toEqual([ - { - type: "project.config.get_import.response", - payload: { - requestId: "import-archived-1", - repoRoot: archivedRoot, - ok: false, - error: { code: "project_not_found" }, - }, - }, - { - type: "project.config.get_import.response", - payload: { - requestId: "import-unknown-1", - repoRoot: unknownRoot, - ok: false, - error: { code: "project_not_found" }, - }, - }, - ]); - expect(serviceCalls).toEqual([]); - }); - - test("import preview emits a fake service preview", async () => { - const repoRoot = makeRoot(); - const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], { - inspect: (input) => ({ - repoRoot: input.repoRoot, - source: input.source, - status: "available", - sourceRevision: "source-revision-1", - paseoRevision: input.paseoRevision, - inputs: [{ role: "shared", relativePath: "source/config.json" }], - items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }], - preview: { worktree: { setup: "npm ci" } }, - }), - apply: () => { - throw new Error("unexpected import apply"); - }, - }); - - await subsystem.handleGetProjectConfigImportRequest({ - type: "project.config.get_import.request", - requestId: "import-preview-1", - repoRoot, - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - - expect(emitted[0]).toMatchObject({ - type: "project.config.get_import.response", - payload: { - requestId: "import-preview-1", - repoRoot, - ok: true, - sourceRevision: "source-revision-1", - inputs: [{ role: "shared", relativePath: "source/config.json" }], - }, - }); - }); - - test("import preview reports invalid source errors from the injected service", async () => { - const repoRoot = makeRoot(); - const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], { - inspect: () => { - throw new InvalidProjectConfigImportSourceError( - PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - "source/config.json", - ); - }, - apply: () => { - throw new Error("unexpected import apply"); - }, - }); - - await subsystem.handleGetProjectConfigImportRequest({ - type: "project.config.get_import.request", - requestId: "import-invalid-1", - repoRoot, - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - }); - - expect(emitted[0]).toMatchObject({ - type: "project.config.get_import.response", - payload: { - requestId: "import-invalid-1", - repoRoot, - ok: false, - error: { - code: "invalid_source_config", - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - relativePath: "source/config.json", - }, - }, - }); - }); - - test("apply import returns the injected service result", async () => { - const repoRoot = makeRoot(); - const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], { - inspect: () => { - throw new Error("unexpected import inspect"); - }, - apply: (input) => ({ - ok: true, - repoRoot: input.repoRoot, - source: input.source, - config: { worktree: { setup: "npm ci" } }, - revision: { mtimeMs: 2, size: 42 }, - items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }], - }), - }); - - await subsystem.handleApplyProjectConfigImportRequest({ - type: "project.config.apply_import.request", - requestId: "import-apply-1", - repoRoot, - source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE, - expectedSourceRevision: "source-revision-1", - expectedPaseoRevision: null, - }); - - expect(emitted[0]).toMatchObject({ - type: "project.config.apply_import.response", - payload: { - requestId: "import-apply-1", - repoRoot, - ok: true, - config: { worktree: { setup: "npm ci" } }, - }, - }); - }); }); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index b0bb6863c..ae62bd3d2 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -8,12 +8,6 @@ import { writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; -import { projectConfigImportRegistry } from "./import/registry.js"; -import { - createProjectConfigImportService, - InvalidProjectConfigImportSourceError, - type ProjectConfigImportService, -} from "./import/service.js"; export interface ProjectConfigSessionHost { emit(msg: SessionOutboundMessage): void; @@ -22,7 +16,6 @@ export interface ProjectConfigSessionHost { export interface ProjectConfigSessionOptions { host: ProjectConfigSessionHost; projectRegistry: Pick; - importService?: ProjectConfigImportService; logger: pino.Logger; } @@ -36,14 +29,11 @@ export interface ProjectConfigSessionOptions { export class ProjectConfigSession { private readonly host: ProjectConfigSessionHost; private readonly projectRegistry: Pick; - private readonly importService: ProjectConfigImportService; private readonly logger: pino.Logger; constructor(options: ProjectConfigSessionOptions) { this.host = options.host; this.projectRegistry = options.projectRegistry; - this.importService = - options.importService ?? createProjectConfigImportService(projectConfigImportRegistry); this.logger = options.logger; } @@ -128,87 +118,6 @@ export class ProjectConfigSession { }); } - async handleGetProjectConfigImportRequest( - msg: Extract, - ): Promise { - const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); - if (!repoRoot) { - this.emitProjectConfigImportGetFailure(msg, { code: "project_not_found" }); - return; - } - - const config = readPaseoConfigForEdit(repoRoot); - if (!config.ok) { - this.emitProjectConfigImportGetFailure(msg, config.error, repoRoot); - return; - } - - try { - const preview = this.importService.inspect({ - repoRoot, - source: msg.source, - paseoConfig: config.config ?? {}, - paseoRevision: config.revision, - }); - this.host.emit({ - type: "project.config.get_import.response", - payload: { - requestId: msg.requestId, - ok: true, - ...preview, - }, - }); - } catch (error) { - if (error instanceof InvalidProjectConfigImportSourceError) { - this.emitProjectConfigImportGetFailure( - msg, - { - code: "invalid_source_config", - source: error.source, - relativePath: error.relativePath, - }, - repoRoot, - ); - return; - } - throw error; - } - } - - async handleApplyProjectConfigImportRequest( - msg: Extract, - ): Promise { - const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); - if (!repoRoot) { - this.emitProjectConfigImportApplyFailure(msg, { code: "project_not_found" }); - return; - } - - const result = this.importService.apply({ - repoRoot, - source: msg.source, - expectedSourceRevision: msg.expectedSourceRevision, - expectedPaseoRevision: msg.expectedPaseoRevision, - }); - if (!result.ok) { - this.emitProjectConfigImportApplyFailure(msg, result.error, repoRoot); - return; - } - - this.host.emit({ - type: "project.config.apply_import.response", - payload: { - requestId: msg.requestId, - repoRoot, - source: result.source, - ok: true, - config: result.config, - revision: result.revision, - items: result.items, - }, - }); - } - private emitProjectConfigReadFailure( msg: Extract, error: ProjectConfigRpcError, @@ -241,38 +150,6 @@ export class ProjectConfigSession { }); } - private emitProjectConfigImportGetFailure( - msg: Extract, - error: ProjectConfigRpcError, - repoRoot = msg.repoRoot, - ): void { - this.host.emit({ - type: "project.config.get_import.response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: false, - error, - }, - }); - } - - private emitProjectConfigImportApplyFailure( - msg: Extract, - error: ProjectConfigRpcError, - repoRoot = msg.repoRoot, - ): void { - this.host.emit({ - type: "project.config.apply_import.response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: false, - error, - }, - }); - } - private async resolveKnownProjectRoot(repoRoot: string): Promise { const requestedRoot = canonicalizeConfigRoot(repoRoot); const projects = await this.projectRegistry.list(); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 284059b66..cb1e92b2f 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import pino from "pino"; import { createPaseoDaemon, + type ListenTarget, type PaseoDaemonConfig, type PaseoOpenAIConfig, type PaseoSpeechConfig, @@ -19,6 +20,7 @@ interface TestPaseoDaemonOptions { downloadTokenTtlMs?: number; corsAllowedOrigins?: string[]; listen?: string; + allowIpc?: boolean; logger?: Parameters[1]; mcpEnabled?: boolean; mcpDebug?: boolean; @@ -49,6 +51,7 @@ export interface TestPaseoDaemon { config: PaseoDaemonConfig; daemon: Awaited>; port: number; + listenTarget: ListenTarget; paseoHome: string; staticDir: string; close: () => Promise; @@ -96,8 +99,8 @@ export async function createTestPaseoDaemon( try { await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS); const listenTarget = daemon.getListenTarget(); - if (!listenTarget || listenTarget.type !== "tcp") { - throw new Error("Test daemon did not expose a bound TCP listen target"); + if (!listenTarget || (listenTarget.type !== "tcp" && !options.allowIpc)) { + throw new Error("Test daemon did not expose an allowed listen target"); } const close = async (): Promise => { @@ -115,7 +118,8 @@ export async function createTestPaseoDaemon( return { config, daemon, - port: listenTarget.port, + port: listenTarget.type === "tcp" ? listenTarget.port : 0, + listenTarget, paseoHome, staticDir, close, @@ -157,7 +161,7 @@ async function prepareTestDaemonConfig( const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-"))); const listenHost = options.listen ?? "127.0.0.1"; const config: PaseoDaemonConfig = { - listen: `${listenHost}:0`, + listen: options.allowIpc ? listenHost : `${listenHost}:0`, paseoHome, daemonVersion: options.daemonVersion, desktopManaged: options.desktopManaged, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ad64bc049..705dcc8da 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -66,7 +66,6 @@ import { type WebSocketRuntimeDiagnosticSnapshot, } from "./websocket/runtime-metrics.js"; import { ProviderUsageService } from "../services/quota-fetcher/service.js"; -import { projectConfigImportRegistry } from "./session/project-config/import/registry.js"; import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js"; import { CLIENT_SHUTDOWN_RPC_REASON, @@ -1248,6 +1247,8 @@ export class VoiceAssistantWebSocketServer { projectRemove: true, // COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97. projectAdd: true, + // COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111. + hostAutomation: true, // COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105. worktreeRestore: true, // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. @@ -1280,8 +1281,6 @@ export class VoiceAssistantWebSocketServer { providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, - // COMPAT(projectConfigImportSources): added in v0.1.110, remove the gate after 2027-01-17. - projectConfigImportSources: projectConfigImportRegistry.sources(), // COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106. forgeProviders: true, }, diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d895df539..b885065b6 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -310,6 +310,28 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main"); }); + it("prunes a verified stale registration before checking out its branch", async () => { + execFileSync("git", ["branch", "stale-branch"], { cwd: repoDir }); + const stalePath = join(tempDir, "stale-worktree"); + execFileSync("git", ["worktree", "add", stalePath, "stale-branch"], { cwd: repoDir }); + rmSync(stalePath, { recursive: true, force: true }); + + const recreated = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "recreated-stale", + source: { kind: "checkout-branch", branchName: "stale-branch" }, + runSetup: false, + paseoHome, + }); + + expect(existsSync(recreated.worktreePath)).toBe(true); + expect( + execFileSync("git", ["branch", "--show-current"], { cwd: recreated.worktreePath }) + .toString() + .trim(), + ).toBe("stale-branch"); + }); + it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4b6f24e87..dee16e0b1 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1202,6 +1202,7 @@ export const createWorktree = async ({ paseoHome, worktreesRoot, }: CreateWorktreeOptions): Promise => { + await pruneVerifiedStaleWorktrees(cwd); const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); mkdirSync(dirname(worktreePath), { recursive: true }); @@ -1270,6 +1271,16 @@ export const createWorktree = async ({ }; }; +async function pruneVerifiedStaleWorktrees(cwd: string): Promise { + const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + const hasStaleRegistration = parseWorktreeList(stdout).some((entry) => !existsSync(entry.path)); + if (!hasStaleRegistration) return; + await runGitCommand(["worktree", "prune"], { cwd, timeout: 30_000 }); +} + interface ResolveWorktreeSourcePlanOptions { cwd: string; source: WorktreeSource; diff --git a/scripts/verify-migrate-package-contents.mjs b/scripts/verify-migrate-package-contents.mjs new file mode 100644 index 000000000..b175bddb6 --- /dev/null +++ b/scripts/verify-migrate-package-contents.mjs @@ -0,0 +1,38 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const packDirectory = mkdtempSync(path.join(os.tmpdir(), "paseo-migrate-pack-")); +try { + const output = execFileSync(npm, ["pack", "--json", "--pack-destination", packDirectory], { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + const [manifest] = JSON.parse(output); + const files = new Set(manifest?.files?.map((file) => file.path)); + const required = [ + "dist/cli.js", + "fixtures/conductor/conductor.db", + "fixtures/conductor/current/.conductor/settings.local.toml", + "fixtures/conductor/current/.conductor/settings.toml", + "fixtures/conductor/legacy/conductor.json", + ]; + const missing = required.filter((file) => !files.has(file)); + if (missing.length > 0) { + throw new Error(`Published migrator is missing: ${missing.join(", ")}`); + } + if ( + typeof manifest.filename !== "string" || + !existsSync(path.join(packDirectory, manifest.filename)) + ) { + throw new Error("npm pack did not create the migrator tarball."); + } + process.stdout.write( + `Verified published migrator tarball contents (${manifest.files.length} files).\n`, + ); +} finally { + rmSync(packDirectory, { recursive: true, force: true }); +}