diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd0ec3112..b97bf4515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,6 @@ 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: @@ -201,7 +200,7 @@ jobs: if-no-files-found: ignore retention-days: 7 - migration-electron: + import-electron: runs-on: macos-latest timeout-minutes: 30 steps: @@ -220,19 +219,17 @@ jobs: 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: Build Desktop main + run: npm run build:main --workspace=@getpaseo/desktop - - name: Run product migration behavior - run: npm run test:migration-electron --workspace=@getpaseo/desktop + - name: Run product import behavior + run: npm run test:import-electron --workspace=@getpaseo/desktop - - name: Upload migration behavior diagnostics + - name: Upload import behavior diagnostics if: failure() uses: actions/upload-artifact@v4 with: - name: migration-electron-macos + name: import-electron-macos path: | packages/app/test-results/ packages/app/playwright-report/ @@ -285,38 +282,9 @@ 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 5355df2ca..776e8d64f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,16 +94,16 @@ facade. App and CLI may import the low-level driver from 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. +published import tools. -### `packages/migrate` — Installation migration CLI +### External project import -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. +The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes +`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the +programmatic import engine. Paseo Desktop pins an exact package version and calls its library +entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed +progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or +import-package release machinery. ### `packages/app` — Mobile + web client (Expo) diff --git a/docs/testing.md b/docs/testing.md index 86cd45701..e5c74b7ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -156,13 +156,13 @@ 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: +Desktop import coverage starts from the real product Integrations row and crosses the real Electron +main process, compiled sandboxed preload, production import IPC, the installed import library, and a +real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and +ineligible-host UI: ```bash -npm run test:migration-electron --workspace=@getpaseo/desktop +npm run test:import-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. diff --git a/package-lock.json b/package-lock.json index 5d12d0f2a..0d635da31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "packages/highlight", "packages/protocol", "packages/client", - "packages/migrate", "packages/server", "packages/app", "packages/relay", @@ -6207,10 +6206,6 @@ "resolved": "packages/highlight", "link": true }, - "node_modules/@getpaseo/migrate": { - "resolved": "packages/migrate", - "link": true - }, "node_modules/@getpaseo/protocol": { "resolved": "packages/protocol", "link": true @@ -12279,13 +12274,6 @@ "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", @@ -12628,17 +12616,6 @@ "@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", @@ -36480,7 +36457,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", - "@getpaseo/migrate": "*", + "@getpaseo/import": "0.1.0-beta.2", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", @@ -36499,6 +36476,26 @@ "wait-on": "8.0.5" } }, + "packages/desktop/node_modules/@getpaseo/import": { + "version": "0.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz", + "integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@getpaseo/protocol": "0.2.0-beta.1", + "smol-toml": "^1.6.0", + "sql.js": "^1.14.1" + }, + "bin": { + "paseo-import": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@getpaseo/client": ">=0.2.0-beta.2" + } + }, "packages/desktop/node_modules/@types/node": { "version": "24.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", @@ -37847,28 +37844,6 @@ } } }, - "packages/migrate": { - "name": "@getpaseo/migrate", - "version": "0.2.0-beta.1", - "dependencies": { - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "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" - }, - "engines": { - "node": ">=18" - } - }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.2.0-beta.1", @@ -38140,7 +38115,6 @@ "zod": "^4.4.3" }, "devDependencies": { - "@getpaseo/migrate": "0.2.0-beta.1", "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", diff --git a/package.json b/package.json index 27babc631..98fa77d80 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "packages/highlight", "packages/protocol", "packages/client", - "packages/migrate", "packages/server", "packages/app", "packages/relay", @@ -99,11 +98,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 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: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: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-import.electron.spec.ts similarity index 78% rename from packages/app/e2e/conductor-migration.electron.spec.ts rename to packages/app/e2e/conductor-import.electron.spec.ts index 9c45ae5b8..a43b0b97f 100644 --- a/packages/app/e2e/conductor-migration.electron.spec.ts +++ b/packages/app/e2e/conductor-import.electron.spec.ts @@ -8,6 +8,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { @@ -22,6 +23,8 @@ test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron p test.setTimeout(180_000); const repoRoot = path.resolve(__dirname, "../../.."); +const nodeRequire = createRequire(__filename); +const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), ".."); let installation: Awaited>; let electronApp: ElectronApplication; @@ -39,29 +42,29 @@ test("imports from the product Integrations row and renders success, failure, an const page = await electronApp.firstWindow(); await openIntegrations(page); - await openMigration(page); + await openImport(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( + await page.getByTestId("import-confirm").click(); + await expect(page.getByTestId("import-result")).toHaveText("Import complete."); + await expect(page.getByTestId("import-output")).toContainText( `Registered project ${installation.repo}.`, ); - await expect(page.getByTestId("migration-output")).toContainText("Migration summary:"); + await expect(page.getByTestId("import-output")).toContainText("Import summary:"); - await page.getByTestId("migration-done").click(); + await page.getByTestId("import-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 openImport(page); + await page.getByTestId("import-confirm").click(); + await expect(page.getByTestId("import-result")).toHaveText("Import failed."); + await expect(page.getByTestId("import-output")).toContainText("ERROR:"); - await page.getByTestId("migration-done").click(); - await stopMigrationHost(); + await page.getByTestId("import-done").click(); + await stopImportHost(); 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"); + const row = page.getByTestId("conductor-import-row"); await expect(row).toContainText("Start the Desktop-managed host before importing."); await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled(); }); @@ -76,7 +79,7 @@ async function launchProduct(): Promise { HOME: installation.home, PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"), PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1", - PASEO_TEST_APP_NAME: "Paseo Migration Behavior", + PASEO_TEST_APP_NAME: "Paseo Import Behavior", }, }); } @@ -87,16 +90,16 @@ async function openIntegrations(page: Page): Promise { 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(); + await expect(page.getByTestId("conductor-import-row")).toBeVisible(); } -async function openMigration(page: Page): Promise { +async function openImport(page: Page): Promise { const button = page - .getByTestId("conductor-migration-row") + .getByTestId("conductor-import-row") .getByRole("button", { name: "Import", exact: true }); await expect(button).toBeEnabled(); await button.click(); - await expect(page.getByTestId("migration-sheet")).toBeVisible(); + await expect(page.getByTestId("import-sheet")).toBeVisible(); } async function createConductorInstallation(): Promise<{ @@ -105,7 +108,7 @@ async function createConductorInstallation(): Promise<{ repo: string; databasePath: string; }> { - const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-migration-"))); + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-"))); const home = path.join(root, "home"); const repo = path.join(root, "repo"); mkdirSync(home, { recursive: true }); @@ -121,10 +124,7 @@ async function createConductorInstallation(): Promise<{ 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, - ); + cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath); execFileSync( process.execPath, [ @@ -150,7 +150,7 @@ const initSqlJs = require("sql.js"); return { root, home, repo: realpathSync(repo), databasePath }; } -async function stopMigrationHost(): Promise { +async function stopImportHost(): 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"); @@ -163,7 +163,7 @@ async function stopMigrationHost(): Promise { return; } } - throw new Error(`Migration host ${pid} did not stop.`); + throw new Error(`Import host ${pid} did not stop.`); } function requiredEnvironment(name: string): string { diff --git a/packages/app/package.json b/packages/app/package.json index 7846d16ab..f9b9e6f40 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -23,7 +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:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.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/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index abff95ecb..a6f083b93 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -16,7 +16,8 @@ import { type SkillsStatus, } from "@/desktop/daemon/desktop-daemon"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; -import { ConductorMigration } from "@/desktop/migrations/conductor"; +import { conductorImportSource } from "@/desktop/imports/conductor"; +import { ExternalImport } from "@/desktop/imports/external-import"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; @@ -202,7 +203,7 @@ export function IntegrationsSection() { onUninstall={handleUninstallSkills} /> - + ); diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 88493bce9..2bd358ea1 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -165,14 +165,15 @@ export interface DesktopInvokeBridge { invoke?: (command: string, args?: Record) => Promise; } -export interface DesktopMigrationOutput { - runId: string; - stream: "stdout" | "stderr" | "status"; - chunk?: string; - exitCode?: number; -} +export type DesktopImportOutput = + | { + runId: string; + type: "event"; + event: { level: "info" | "warning" | "error"; message: string }; + } + | { runId: string; type: "status"; succeeded: boolean }; -export interface DesktopMigrationsBridge { +export interface DesktopImportsBridge { getAvailability?: (input: { source: string }) => Promise<{ available: boolean; reason: @@ -181,12 +182,11 @@ export interface DesktopMigrationsBridge { | "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; + onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void; } export interface DesktopHostBridge { @@ -202,7 +202,7 @@ export interface DesktopHostBridge { webUtils?: DesktopWebUtilsBridge; menu?: DesktopMenuBridge; browser?: DesktopBrowserBridge; - migrations?: DesktopMigrationsBridge; + imports?: DesktopImportsBridge; } declare global { diff --git a/packages/app/src/desktop/migrations/conductor.svg b/packages/app/src/desktop/imports/conductor.svg similarity index 100% rename from packages/app/src/desktop/migrations/conductor.svg rename to packages/app/src/desktop/imports/conductor.svg diff --git a/packages/app/src/desktop/imports/conductor.ts b/packages/app/src/desktop/imports/conductor.ts new file mode 100644 index 000000000..ee6ed8736 --- /dev/null +++ b/packages/app/src/desktop/imports/conductor.ts @@ -0,0 +1,7 @@ +import type { ImportSourceDescriptor } from "./import-sheet"; + +export const conductorImportSource: ImportSourceDescriptor = { + id: "conductor", + title: "Conductor", + icon: require("./conductor.svg"), +}; diff --git a/packages/app/src/desktop/imports/external-import.tsx b/packages/app/src/desktop/imports/external-import.tsx new file mode 100644 index 000000000..0b7044342 --- /dev/null +++ b/packages/app/src/desktop/imports/external-import.tsx @@ -0,0 +1,47 @@ +import { Image, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { isElectronRuntimeMac } from "@/desktop/host"; +import { settingsStyles } from "@/styles/settings"; +import { ImportSheet, type ImportSourceDescriptor, useImportAvailability } from "./import-sheet"; + +export function ExternalImport({ source }: { source: ImportSourceDescriptor }) { + const { t } = useTranslation(); + const importFlow = useImportAvailability(source.id); + if (!isElectronRuntimeMac()) return null; + + return ( + <> + + + + + {source.title} + + + {importFlow.reason ?? t("desktop.integrations.import.description")} + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + icon: { width: theme.iconSize.md, height: theme.iconSize.md }, +})); diff --git a/packages/app/src/desktop/migrations/migration-sheet.tsx b/packages/app/src/desktop/imports/import-sheet.tsx similarity index 66% rename from packages/app/src/desktop/migrations/migration-sheet.tsx rename to packages/app/src/desktop/imports/import-sheet.tsx index de4017b8c..f3d6681b7 100644 --- a/packages/app/src/desktop/migrations/migration-sheet.tsx +++ b/packages/app/src/desktop/imports/import-sheet.tsx @@ -5,44 +5,39 @@ 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"; +import { getDesktopHost, type DesktopImportOutput } from "@/desktop/host"; -type MigrationState = +type ImportState = | { status: "confirm" } | { status: "running"; runId: string | null; output: string } | { status: "complete"; succeeded: boolean; output: string } | { status: "failed"; message: string; output: string }; -export interface MigrationSourceDescriptor { +export interface ImportSourceDescriptor { id: string; icon: ImageSourcePropType; title: string; - description: string; - sheetTitle: string; - confirmation: string; } -export function useMigrationAvailability(source: string) { +export function useImportAvailability(source: string) { const { t } = useTranslation(); const [availability, setAvailability] = useState<{ available: boolean; reason: string | null; }>({ available: false, - reason: t("desktop.integrations.migration.availability.checking"), + reason: t("desktop.integrations.import.availability.checking"), }); const [visible, setVisible] = useState(false); useEffect(() => { let active = true; let retry: ReturnType | null = null; async function loadAvailability() { - const next = await getDesktopHost()?.migrations?.getAvailability?.({ source }); + const next = await getDesktopHost()?.imports?.getAvailability?.({ source }); if (active && next) { setAvailability({ available: next.available, - reason: next.reason - ? t(`desktop.integrations.migration.availability.${next.reason}`) - : null, + reason: next.reason ? t(`desktop.integrations.import.availability.${next.reason}`) : null, }); if (next.reason === "host-not-running") { retry = setTimeout(() => void loadAvailability(), 2_000); @@ -63,18 +58,21 @@ export function useMigrationAvailability(source: string) { }; } -export function MigrationSheet({ +export function ImportSheet({ source, visible, onClose, }: { - source: MigrationSourceDescriptor; + source: ImportSourceDescriptor; visible: boolean; onClose: () => void; }) { const { t } = useTranslation(); - const [state, setState] = useState({ status: "confirm" }); - const header = useMemo(() => ({ title: source.sheetTitle }), [source.sheetTitle]); + const [state, setState] = useState({ status: "confirm" }); + const header = useMemo( + () => ({ title: t("desktop.integrations.import.sheetTitle", { source: source.title }) }), + [source.title, t], + ); const close = useCallback(() => { if (state.status !== "running") onClose(); }, [onClose, state.status]); @@ -84,21 +82,21 @@ export function MigrationSheet({ }, [visible]); const start = useCallback(async () => { - const bridge = getDesktopHost()?.migrations; + const bridge = getDesktopHost()?.imports; if (!bridge?.run || !bridge.onOutput) { setState({ status: "failed", - message: t("desktop.integrations.migration.unavailable"), + message: t("desktop.integrations.import.unavailable"), output: "", }); return; } setState({ status: "running", runId: null, output: "" }); let runId: string | null = null; - const unsubscribe = bridge.onOutput((event: DesktopMigrationOutput) => { + const unsubscribe = bridge.onOutput((event: DesktopImportOutput) => { if (runId && event.runId !== runId) return; setState((current) => reduceOutput(current, event)); - if (event.stream === "status") unsubscribe(); + if (event.type === "status") unsubscribe(); }); try { const started = await bridge.run({ source: source.id }); @@ -119,17 +117,17 @@ export function MigrationSheet({ const footer = useMemo(() => { if (state.status === "confirm") { return ( - ); } if (state.status === "running") { - return ; + return ; } return ( - ); }, [close, start, state.status, t]); @@ -141,26 +139,28 @@ export function MigrationSheet({ onClose={close} footer={footer} desktopMaxWidth={640} - testID="migration-sheet" + testID="import-sheet" > {state.status === "confirm" ? ( - {source.confirmation} + + {t("desktop.integrations.import.confirmation", { source: source.title })} + ) : ( {state.status === "complete" ? ( - + {state.succeeded - ? t("desktop.integrations.migration.complete") - : t("desktop.integrations.migration.failed")} + ? t("desktop.integrations.import.complete") + : t("desktop.integrations.import.failed")} ) : null} {state.status === "failed" ? ( - + {state.message} ) : null} - + {state.output} @@ -170,12 +170,13 @@ export function MigrationSheet({ ); } -function reduceOutput(state: MigrationState, event: DesktopMigrationOutput): MigrationState { +function reduceOutput(state: ImportState, event: DesktopImportOutput): ImportState { if (state.status !== "running") return state; - if (event.stream === "status") { - return { status: "complete", succeeded: event.exitCode === 0, output: state.output }; + if (event.type === "status") { + return { status: "complete", succeeded: event.succeeded, output: state.output }; } - return { ...state, output: `${state.output}${event.chunk ?? ""}` }; + const line = `${event.event.level.toUpperCase()}: ${event.event.message}\n`; + return { ...state, output: `${state.output}${line}` }; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/desktop/migrations/conductor.tsx b/packages/app/src/desktop/migrations/conductor.tsx deleted file mode 100644 index 6a2f31ab5..000000000 --- a/packages/app/src/desktop/migrations/conductor.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 }, -})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 461e38e38..3270480e9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1117,28 +1117,24 @@ export const ar: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "جارٍ التحقق من توفّر الترحيل…", - "unsupported-source": "مصدر الترحيل هذا غير متاح.", + checking: "جارٍ التحقق من توفّر الاستيراد…", + "unsupported-source": "مصدر الاستيراد هذا غير متاح.", "host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.", "nonlocal-host": "الاستيراد غير متاح لمضيف غير محلي.", "password-protected": "الاستيراد غير متاح أثناء حماية المضيف المحلي بكلمة مرور.", "host-version-mismatch": "حدّث المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.", - "migrator-version-mismatch": "أداة الترحيل المضمّنة لا تطابق إصدار تطبيق سطح المكتب هذا.", - unavailable: "ترحيل تطبيق سطح المكتب غير متاح.", + unavailable: "استيراد تطبيق سطح المكتب غير متاح.", }, actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" }, - unavailable: "الترحيل عبر تطبيق سطح المكتب غير متاح.", + unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.", complete: "اكتمل الاستيراد.", failed: "فشل الاستيراد.", - conductor: { - title: "Conductor", - description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.", - sheetTitle: "الاستيراد من Conductor", - confirmation: - "سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات Conductor.", - }, + description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.", + sheetTitle: "الاستيراد من {{source}}", + confirmation: + "سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات {{source}}.", }, cli: { statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ad2d0bfb5..a2cdf18bd 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1128,28 +1128,24 @@ export const en = { }, }, integrations: { - migration: { + import: { availability: { - checking: "Checking migration availability…", - "unsupported-source": "This migration source is unavailable.", + checking: "Checking import availability…", + "unsupported-source": "This import 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.", + unavailable: "Desktop import is unavailable.", }, actions: { import: "Import", importing: "Importing…", done: "Done" }, - unavailable: "Desktop migration is unavailable.", + unavailable: "Desktop import 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.", - }, + description: "Import projects, settings, and worktrees from this Mac.", + sheetTitle: "Import from {{source}}", + confirmation: + "Paseo will register valid repositories, merge supported project settings, and adopt or recreate ready worktrees. {{source}} data will not be changed.", }, cli: { statusFailed: "Unable to check CLI install status.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8dc588254..09ee6030d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1157,30 +1157,25 @@ export const es: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "Comprobando la disponibilidad de la migración…", - "unsupported-source": "Esta fuente de migración no está disponible.", + checking: "Comprobando la disponibilidad de la importación…", + "unsupported-source": "Esta fuente de importació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.", + unavailable: "La importación de Desktop no está disponible.", }, actions: { import: "Importar", importing: "Importando…", done: "Listo" }, - unavailable: "La migración de escritorio no está disponible.", + unavailable: "La importació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.", - }, + description: "Importa proyectos, ajustes y worktrees desde este Mac.", + sheetTitle: "Importar desde {{source}}", + confirmation: + "Paseo registrará los repositorios válidos, combinará los ajustes de proyecto compatibles y adoptará o recreará los worktrees preparados. Los datos de {{source}} no se modificarán.", }, cli: { statusFailed: "No se puede verificar el estado de instalación deCLI.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 48204cebd..ba1b71a4e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1159,30 +1159,25 @@ export const fr: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "Vérification de la disponibilité de la migration…", - "unsupported-source": "Cette source de migration est indisponible.", + checking: "Vérification de la disponibilité de l’importation…", + "unsupported-source": "Cette source d’importation 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.", + unavailable: "L’importation Desktop est indisponible.", }, actions: { import: "Importer", importing: "Importation…", done: "Terminé" }, - unavailable: "La migration depuis l’application de bureau est indisponible.", + unavailable: "L’importation 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.", - }, + description: "Importez les projets, réglages et worktrees depuis ce Mac.", + sheetTitle: "Importer depuis {{source}}", + 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 {{source}} ne seront pas modifiées.", }, cli: { statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e8368f0b5..c73ffa7df 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1132,29 +1132,25 @@ export const ja: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "移行が利用可能か確認しています…", - "unsupported-source": "この移行元は利用できません。", + checking: "インポートが利用可能か確認しています…", + "unsupported-source": "このインポート元は利用できません。", "host-not-running": "インポートする前にDesktop管理ホストを起動してください。", "nonlocal-host": "ローカル以外のホストではインポートできません。", "password-protected": "ローカルホストがパスワード保護されている間はインポートできません。", "host-version-mismatch": "インポートする前にDesktop管理ホストを更新してください。", - "migrator-version-mismatch": "同梱の移行ツールはこのDesktopバージョンと一致しません。", - unavailable: "Desktop移行は利用できません。", + unavailable: "Desktopインポートは利用できません。", }, actions: { import: "インポート", importing: "インポート中…", done: "完了" }, - unavailable: "デスクトップ移行は利用できません。", + unavailable: "デスクトップインポートは利用できません。", complete: "インポートが完了しました。", failed: "インポートに失敗しました。", - conductor: { - title: "Conductor", - description: "このMacからプロジェクト、設定、worktreeをインポートします。", - sheetTitle: "Conductorからインポート", - confirmation: - "Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。Conductorのデータは変更されません。", - }, + description: "このMacからプロジェクト、設定、worktreeをインポートします。", + sheetTitle: "{{source}}からインポート", + confirmation: + "Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。{{source}}のデータは変更されません。", }, cli: { statusFailed: "CLIのインストール状態を確認できません。", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b1ac1268d..81374da6b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1144,30 +1144,25 @@ export const ptBR: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "Verificando a disponibilidade da migração…", - "unsupported-source": "Esta fonte de migração não está disponível.", + checking: "Verificando a disponibilidade da importação…", + "unsupported-source": "Esta fonte de importaçã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.", + unavailable: "A importaçã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.", + unavailable: "A importaçã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.", - }, + description: "Importe projetos, configurações e worktrees deste Mac.", + sheetTitle: "Importar do {{source}}", + confirmation: + "O Paseo registrará repositórios válidos, mesclará configurações de projeto compatíveis e adotará ou recriará worktrees prontos. Os dados do {{source}} não serão alterados.", }, cli: { statusFailed: "Não foi possível verificar o status de instalação da CLI.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d5d038b8c..b8b01e757 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1147,28 +1147,24 @@ export const ru: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "Проверка доступности миграции…", - "unsupported-source": "Этот источник миграции недоступен.", + checking: "Проверка доступности импорта…", + "unsupported-source": "Этот источник импорта недоступен.", "host-not-running": "Запустите управляемый Desktop хост перед импортом.", "nonlocal-host": "Импорт недоступен для нелокального хоста.", "password-protected": "Импорт недоступен, пока локальный хост защищён паролем.", "host-version-mismatch": "Обновите управляемый Desktop хост перед импортом.", - "migrator-version-mismatch": "Встроенный мигратор не соответствует этой версии Desktop.", - unavailable: "Миграция Desktop недоступна.", + unavailable: "Импорт в Desktop недоступен.", }, actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" }, - unavailable: "Миграция в настольном приложении недоступна.", + unavailable: "Импорт в настольном приложении недоступен.", complete: "Импорт завершён.", failed: "Не удалось выполнить импорт.", - conductor: { - title: "Conductor", - description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.", - sheetTitle: "Импорт из Conductor", - confirmation: - "Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные Conductor не изменятся.", - }, + description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.", + sheetTitle: "Импорт из {{source}}", + confirmation: + "Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные {{source}} не изменятся.", }, cli: { statusFailed: "Невозможно проверить статус установки CLI.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index a0ee0e224..96541b974 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1103,28 +1103,24 @@ export const zhCN: TranslationResources = { }, }, integrations: { - migration: { + import: { availability: { - checking: "正在检查迁移是否可用…", - "unsupported-source": "此迁移来源不可用。", + checking: "正在检查导入是否可用…", + "unsupported-source": "此导入来源不可用。", "host-not-running": "请先启动由桌面端管理的主机再导入。", "nonlocal-host": "非本地主机无法使用导入。", "password-protected": "本地主机受密码保护时无法导入。", "host-version-mismatch": "请先更新由桌面端管理的主机再导入。", - "migrator-version-mismatch": "内置迁移工具与当前桌面端版本不匹配。", - unavailable: "桌面端迁移不可用。", + unavailable: "桌面端导入不可用。", }, actions: { import: "导入", importing: "正在导入…", done: "完成" }, - unavailable: "桌面端迁移不可用。", + unavailable: "桌面端导入不可用。", complete: "导入完成。", failed: "导入失败。", - conductor: { - title: "Conductor", - description: "从这台 Mac 导入项目、设置和工作树。", - sheetTitle: "从 Conductor 导入", - confirmation: - "Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。Conductor 数据不会被修改。", - }, + description: "从这台 Mac 导入项目、设置和工作树。", + sheetTitle: "从 {{source}} 导入", + confirmation: + "Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。{{source}} 数据不会被修改。", }, cli: { statusFailed: "无法检查 CLI 安装状态。", diff --git a/packages/client/src/node.ts b/packages/client/src/node.ts index 265150112..9a8db812d 100644 --- a/packages/client/src/node.ts +++ b/packages/client/src/node.ts @@ -438,7 +438,7 @@ export async function connectHostAutomation( 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.", + "This Paseo host does not support project import. Update the host to use this.", ); } return new DaemonHostAutomation(client); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4ad8c0f4d..e155d633f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,10 +15,10 @@ }, "main": "dist/main.js", "scripts": { - "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": "npm --prefix ../.. run build:server:clean && 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", + "test:import-electron": "npm --prefix ../.. run test:e2e:import-electron --workspace=@getpaseo/app", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", "test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs", @@ -28,7 +28,7 @@ }, "dependencies": { "@getpaseo/cli": "*", - "@getpaseo/migrate": "*", + "@getpaseo/import": "0.1.0-beta.2", "@getpaseo/server": "*", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 5fdc10ebf..2f2d0c22f 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -101,9 +101,10 @@ describe("desktop packaging", () => { }; const deps = pkg.dependencies ?? {}; - for (const required of ["@getpaseo/cli", "@getpaseo/migrate", "@getpaseo/server"]) { + for (const required of ["@getpaseo/cli", "@getpaseo/server"]) { expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); } + expect(deps["@getpaseo/import"]).toBe("0.1.0-beta.2"); }); it("launches the packaged macOS CLI through Helper instead of the main app executable", () => { diff --git a/packages/desktop/src/integrations/migrations/ipc.ts b/packages/desktop/src/integrations/imports/ipc.ts similarity index 51% rename from packages/desktop/src/integrations/migrations/ipc.ts rename to packages/desktop/src/integrations/imports/ipc.ts index b5657c41d..d03366ddd 100644 --- a/packages/desktop/src/integrations/migrations/ipc.ts +++ b/packages/desktop/src/integrations/imports/ipc.ts @@ -1,28 +1,24 @@ -import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import { connectHostAutomation } from "@getpaseo/client/node"; +import { runImport } from "@getpaseo/import"; 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"; +import { DesktopImportRunner, type ImportTarget } from "./runner.js"; -const migrations = new DesktopMigrationProcess({ - sources: new Map(process.platform === "darwin" ? [["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"], +const imports = new DesktopImportRunner({ + sources: new Map(process.platform === "darwin" ? [["conductor", { kind: "conductor" }]] : []), + runImport, + connect: (target) => + connectHostAutomation({ + appVersion: target.appVersion, + host: target.listen ?? undefined, + env: { PASEO_HOME: target.home }, }), - getTarget: async (): Promise => { + getTarget: async (): Promise => { const status = await resolveDesktopDaemonStatus(); return { status: status.status, @@ -36,15 +32,15 @@ const migrations = new DesktopMigrationProcess({ }, }); -export function registerMigrationIpc(): void { - ipcMain.handle("paseo:migrations:availability", (_event, input: unknown) => - migrations.availability(readSource(input)), +export function registerImportIpc(): void { + ipcMain.handle("paseo:imports:availability", (_event, input: unknown) => + imports.availability(readSource(input)), ); - ipcMain.handle("paseo:migrations:run", async (event, input: unknown) => { + ipcMain.handle("paseo:imports: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); + runId: await imports.run(source, (output) => { + if (!event.sender.isDestroyed()) event.sender.send("paseo:imports:output", output); }), }; }); @@ -52,10 +48,10 @@ export function registerMigrationIpc(): void { function readSource(input: unknown): string { if (typeof input !== "object" || input === null || !("source" in input)) { - throw new Error("Migration source is required."); + throw new Error("Import source is required."); } const source = (input as { source?: unknown }).source; - if (typeof source !== "string") throw new Error("Migration source is required."); + if (typeof source !== "string") throw new Error("Import source is required."); return source; } diff --git a/packages/desktop/src/integrations/imports/runner.test.ts b/packages/desktop/src/integrations/imports/runner.test.ts new file mode 100644 index 000000000..654c41ebe --- /dev/null +++ b/packages/desktop/src/integrations/imports/runner.test.ts @@ -0,0 +1,182 @@ +import type { ImportResult, PaseoImportHost, RunImportOptions } from "@getpaseo/import"; +import { expect, test } from "vitest"; +import { DesktopImportRunner, type DesktopImportOutput, type ImportTarget } from "./runner.js"; + +const eligibleTarget: ImportTarget = { + status: "running", + desktopManaged: true, + listen: "unix:///tmp/paseo.sock", + home: "/tmp/paseo-home", + appVersion: "0.1.110", + daemonVersion: "0.1.110", + passwordProtected: false, +}; + +test("calls the installed library and streams typed progress", async () => { + const outputs: DesktopImportOutput[] = []; + const host = createHost(); + const runner = createRunner({ + host, + runImport: async (options) => { + options.onEvent?.({ level: "info", message: "Found one project." }); + return emptyResult(); + }, + }); + + const runId = await runner.run("source-fixture", (output) => outputs.push(output)); + await waitForCompletion(outputs); + + expect(outputs).toEqual([ + { + runId, + type: "event", + event: { level: "info", message: "Found one project." }, + }, + { runId, type: "status", succeeded: true }, + ]); + expect(host.closed).toBe(true); +}); + +test("allows only one import at a time", async () => { + let finish: (() => void) | undefined; + const runner = createRunner({ + runImport: () => + new Promise((resolve) => { + finish = () => resolve(emptyResult()); + }), + }); + + await runner.run("source-fixture", () => undefined); + await expect(runner.run("source-fixture", () => undefined)).rejects.toThrow( + "An import is already running.", + ); + finish?.(); + await waitUntil(() => runner.run("source-fixture", () => undefined)); +}); + +test("reports library failures and closes the host", async () => { + const outputs: DesktopImportOutput[] = []; + const host = createHost(); + const runner = createRunner({ + host, + runImport: async () => { + throw new Error("Source database is unreadable."); + }, + }); + + const runId = await runner.run("source-fixture", (output) => outputs.push(output)); + await waitForCompletion(outputs); + + expect(outputs).toEqual([ + { + runId, + type: "event", + event: { level: "error", message: "Source database is unreadable." }, + }, + { runId, type: "status", succeeded: false }, + ]); + expect(host.closed).toBe(true); +}); + +test.each([ + [{ ...eligibleTarget, passwordProtected: true }, "password-protected"], + [{ ...eligibleTarget, listen: "10.0.0.5:6767" }, "nonlocal-host"], + [{ ...eligibleTarget, status: "stopped" as const }, "host-not-running"], + [{ ...eligibleTarget, daemonVersion: "0.1.109" }, "host-version-mismatch"], +] as const)("returns a localizable availability reason", async (target, reason) => { + const runner = createRunner({ target }); + + await expect(runner.availability("source-fixture")).resolves.toEqual({ + available: false, + reason, + }); +}); + +test.each(["[::1]:6767", "::1:6767"])("accepts local IPv6 listen address %s", async (listen) => { + const runner = createRunner({ target: { ...eligibleTarget, listen } }); + + await expect(runner.availability("source-fixture")).resolves.toEqual({ + available: true, + reason: null, + }); +}); + +test("rejects an unregistered source before target lookup or connection", async () => { + let targetRead = false; + let connected = false; + const runner = new DesktopImportRunner({ + sources: new Map(), + getTarget: async () => { + targetRead = true; + return eligibleTarget; + }, + connect: async () => { + connected = true; + return createHost(); + }, + runImport: async () => emptyResult(), + }); + + await expect(runner.run("unknown-source", () => undefined)).rejects.toThrow( + "Unsupported import source: unknown-source", + ); + expect(targetRead).toBe(false); + expect(connected).toBe(false); +}); + +function createRunner(options: { + target?: ImportTarget; + host?: TestHost; + runImport?: (options: RunImportOptions) => Promise; +}): DesktopImportRunner { + return new DesktopImportRunner({ + sources: new Map([["source-fixture", { kind: "conductor" }]]), + getTarget: async () => options.target ?? eligibleTarget, + connect: async () => options.host ?? createHost(), + runImport: options.runImport ?? (async () => emptyResult()), + createRunId: () => "run-1", + }); +} + +interface TestHost extends PaseoImportHost { + closed: boolean; + close(): Promise; +} + +function createHost(): TestHost { + return { + closed: false, + addProject: async () => undefined, + openCheckout: async () => undefined, + readProjectConfig: async () => ({ config: null, revision: null }), + writeProjectConfig: async () => undefined, + ensureCheckout: async () => ({ path: "", created: false }), + async close() { + this.closed = true; + }, + }; +} + +function emptyResult() { + return { + inventory: { projects: [], skippedSettings: [] }, + notices: [], + }; +} + +async function waitForCompletion(outputs: DesktopImportOutput[]): Promise { + await waitUntil(() => outputs.some((output) => output.type === "status")); +} + +async function waitUntil(read: () => T | Promise): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + const value = await read(); + if (value) return value; + } catch (error) { + if (attempt === 19) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Expected import state was not reached."); +} diff --git a/packages/desktop/src/integrations/imports/runner.ts b/packages/desktop/src/integrations/imports/runner.ts new file mode 100644 index 000000000..6719b3def --- /dev/null +++ b/packages/desktop/src/integrations/imports/runner.ts @@ -0,0 +1,168 @@ +import { randomUUID } from "node:crypto"; +import type { + ImportEvent, + ImportResult, + ImportSourceInput, + PaseoImportHost, + RunImportOptions, +} from "@getpaseo/import"; + +export interface ImportTarget { + status: "starting" | "running" | "stopped" | "errored"; + desktopManaged: boolean; + listen: string | null; + home: string; + appVersion: string; + daemonVersion: string | null; + passwordProtected: boolean; +} + +export type DesktopImportOutput = + | { runId: string; type: "event"; event: ImportEvent } + | { runId: string; type: "status"; succeeded: boolean }; + +export type ImportUnavailableReason = + | "unsupported-source" + | "host-not-running" + | "nonlocal-host" + | "password-protected" + | "host-version-mismatch" + | "unavailable"; + +type ConnectedImportHost = PaseoImportHost & { close(): Promise }; + +interface ImportRunnerDependencies { + sources: ReadonlyMap; + getTarget(): Promise; + connect(target: ImportTarget): Promise; + runImport(options: RunImportOptions): Promise; + createRunId?(): string; +} + +export class DesktopImportRunner { + private activeRunId: string | null = null; + + constructor(private readonly dependencies: ImportRunnerDependencies) {} + + async availability( + sourceId: string, + ): Promise<{ available: boolean; reason: ImportUnavailableReason | null }> { + try { + this.requireSource(sourceId); + assertEligibleTarget(await this.dependencies.getTarget()); + return { available: true, reason: null }; + } catch (error) { + return { + available: false, + reason: error instanceof ImportEligibilityError ? error.reason : "unavailable", + }; + } + } + + async run(sourceId: string, emit: (output: DesktopImportOutput) => void): Promise { + const source = this.requireSource(sourceId); + if (this.activeRunId) throw new Error("An import is already running."); + this.activeRunId = "starting"; + try { + const target = await this.dependencies.getTarget(); + assertEligibleTarget(target); + const host = await this.dependencies.connect(target); + const runId = this.dependencies.createRunId?.() ?? randomUUID(); + this.activeRunId = runId; + void this.execute({ runId, source, host, emit }); + return runId; + } catch (error) { + this.activeRunId = null; + throw error; + } + } + + private async execute(input: { + runId: string; + source: ImportSourceInput; + host: ConnectedImportHost; + emit(output: DesktopImportOutput): void; + }): Promise { + let succeeded = false; + try { + const result = await this.dependencies.runImport({ + source: input.source, + host: input.host, + onEvent: (event) => input.emit({ runId: input.runId, type: "event", event }), + }); + succeeded = !result.notices.some((notice) => notice.level === "error"); + } catch (error) { + input.emit({ + runId: input.runId, + type: "event", + event: { + level: "error", + message: error instanceof Error ? error.message : String(error), + }, + }); + } finally { + await input.host.close().catch(() => undefined); + this.activeRunId = null; + input.emit({ runId: input.runId, type: "status", succeeded }); + } + } + + private requireSource(sourceId: string): ImportSourceInput { + const source = this.dependencies.sources.get(sourceId); + if (!source) { + throw new ImportEligibilityError( + "unsupported-source", + `Unsupported import source: ${sourceId}`, + ); + } + return source; + } +} + +export function assertEligibleTarget(target: ImportTarget): void { + if (target.status !== "running" || !target.desktopManaged) { + throw new ImportEligibilityError( + "host-not-running", + "Import requires the running Paseo Desktop-managed host.", + ); + } + if (!isLocalListen(target.listen)) { + throw new ImportEligibilityError("nonlocal-host", "Import is unavailable for a nonlocal host."); + } + if (target.passwordProtected) { + throw new ImportEligibilityError( + "password-protected", + "Import is unavailable while the local host is password-protected.", + ); + } + if (normalizeVersion(target.daemonVersion) !== normalizeVersion(target.appVersion)) { + throw new ImportEligibilityError( + "host-version-mismatch", + "Update the Desktop-managed host before importing.", + ); + } +} + +class ImportEligibilityError extends Error { + constructor( + readonly reason: ImportUnavailableReason, + 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]:") || endpoint.startsWith("::1:")) return true; + const host = endpoint.split(":")[0]; + return host === "127.0.0.1" || host === "localhost" || host === "[::1]"; +} + +function normalizeVersion(version: string | null): string | null { + return version?.trim().replace(/^v/i, "") || null; +} diff --git a/packages/desktop/src/integrations/migrations/entrypoint.test.ts b/packages/desktop/src/integrations/migrations/entrypoint.test.ts deleted file mode 100644 index 80701c09c..000000000 --- a/packages/desktop/src/integrations/migrations/entrypoint.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 6166abae7..000000000 --- a/packages/desktop/src/integrations/migrations/entrypoint.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/process.test.ts b/packages/desktop/src/integrations/migrations/process.test.ts deleted file mode 100644 index 3162ce07b..000000000 --- a/packages/desktop/src/integrations/migrations/process.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -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); - child.stdout.write("Migration complete\n"); - child.emitClose(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: "stdout", chunk: "Migration complete\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)).rejects.toThrow( - "A migration is already running.", - ); - child.emitClose(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.each(["[::1]:6767", "::1:6767"])("accepts local IPv6 listen address %s", async (listen) => { - const migrations = createMigrations({ ...eligibleTarget, listen }, fakeChild().process); - - await expect(migrations.availability("source-fixture")).resolves.toEqual({ - available: true, - reason: null, - }); -}); - -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; - emitClose(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), - emitClose: (code) => events.emit("close", code, null), - }; -} diff --git a/packages/desktop/src/integrations/migrations/process.ts b/packages/desktop/src/integrations/migrations/process.ts deleted file mode 100644 index 9f8c15c64..000000000 --- a/packages/desktop/src/integrations/migrations/process.ts +++ /dev/null @@ -1,206 +0,0 @@ -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("close", (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]:") || 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 275f1452b..bded42242 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -89,7 +89,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"; +import { registerImportIpc } from "./integrations/imports/ipc.js"; import { installAppUpdateOnQuit } from "./features/auto-updater.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; @@ -882,7 +882,7 @@ async function bootstrap(): Promise { registerOpenerHandlers(); registerEditorTargetHandlers(); registerBrowserAutomationIpc(); - registerMigrationIpc(); + registerImportIpc(); // 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 87f09ab50..f8a803790 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -17,12 +17,13 @@ interface AttachedBrowserRegistration { webContentsId: number; } -interface DesktopMigrationOutput { - runId: string; - stream: "stdout" | "stderr" | "status"; - chunk?: string; - exitCode?: number; -} +type DesktopImportOutput = + | { + runId: string; + type: "event"; + event: { level: "info" | "warning" | "error"; message: string }; + } + | { runId: string; type: "status"; succeeded: boolean }; contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, @@ -41,9 +42,9 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }); }, }, - migrations: { + imports: { getAvailability: (input: { source: string }) => - ipcRenderer.invoke("paseo:migrations:availability", input) as Promise<{ + ipcRenderer.invoke("paseo:imports:availability", input) as Promise<{ available: boolean; reason: | "unsupported-source" @@ -51,18 +52,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", { | "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) => { + ipcRenderer.invoke("paseo:imports:run", input) as Promise<{ runId: string }>, + onOutput: (handler: (output: DesktopImportOutput) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, output: DesktopImportOutput) => { handler(output); }; - ipcRenderer.on("paseo:migrations:output", listener); - return () => ipcRenderer.removeListener("paseo:migrations:output", listener); + ipcRenderer.on("paseo:imports:output", listener); + return () => ipcRenderer.removeListener("paseo:imports:output", listener); }, }, window: { diff --git a/packages/migrate/fixtures/conductor/conductor.db b/packages/migrate/fixtures/conductor/conductor.db deleted file mode 100644 index 84fa10bb4..000000000 Binary files a/packages/migrate/fixtures/conductor/conductor.db and /dev/null differ diff --git a/packages/migrate/fixtures/conductor/current/.conductor/settings.local.toml b/packages/migrate/fixtures/conductor/current/.conductor/settings.local.toml deleted file mode 100644 index f35c4c50e..000000000 --- a/packages/migrate/fixtures/conductor/current/.conductor/settings.local.toml +++ /dev/null @@ -1,5 +0,0 @@ -[scripts] -setup = "npm ci --prefer-offline" - -[scripts.run.test] -command = "npm test" diff --git a/packages/migrate/fixtures/conductor/current/.conductor/settings.toml b/packages/migrate/fixtures/conductor/current/.conductor/settings.toml deleted file mode 100644 index 6d5369ac8..000000000 --- a/packages/migrate/fixtures/conductor/current/.conductor/settings.toml +++ /dev/null @@ -1,13 +0,0 @@ -[scripts] -setup = "npm ci && echo $CONDUCTOR_WORKSPACE_PATH" -archive = "npm run cleanup" - -[scripts.run.dev] -command = "npm run dev -- --port $CONDUCTOR_PORT" - -[environment_variables] -SECRET_TOKEN = "fixture-secret-never-print" - -[prompts] -title = "Write a concise task title" -unknown_prompt = "not portable" diff --git a/packages/migrate/fixtures/conductor/legacy/conductor.json b/packages/migrate/fixtures/conductor/legacy/conductor.json deleted file mode 100644 index 4cc5411e5..000000000 --- a/packages/migrate/fixtures/conductor/legacy/conductor.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "scripts": { - "setup": "legacy setup", - "archive": "legacy teardown", - "run": { - "test": { - "command": "legacy test" - } - } - } -} diff --git a/packages/migrate/package.json b/packages/migrate/package.json deleted file mode 100644 index 056e3711e..000000000 --- a/packages/migrate/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@getpaseo/migrate", - "version": "0.2.0-beta.1", - "description": "Import projects from other development environments into Paseo", - "bin": { - "paseo-migrate": "dist/cli.js" - }, - "files": [ - "dist", - "fixtures", - "!dist/**/*.map" - ], - "type": "module", - "exports": { - ".": { - "types": "./dist/migrate.d.ts", - "default": "./dist/migrate.js" - }, - "./cli": { - "types": "./dist/cli.d.ts", - "default": "./dist/cli.js" - } - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "clean": "node ../../scripts/clean-package-dist.mjs", - "build": "tsc -p tsconfig.json --incremental false", - "build:clean": "npm run clean && npm run build", - "prepack": "npm run build:clean", - "verify:package": "npm run build:clean && node ../../scripts/verify-migrate-package-contents.mjs", - "typecheck": "tsgo --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "smol-toml": "^1.6.0", - "sql.js": "^1.14.1" - }, - "devDependencies": { - "@types/node": "^20.9.0", - "@types/sql.js": "^1.4.11", - "typescript": "^5.2.2", - "vitest": "^4.1.6" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/migrate/src/cli.test.ts b/packages/migrate/src/cli.test.ts deleted file mode 100644 index 4ad27961d..000000000 --- a/packages/migrate/src/cli.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 685eaebdc..000000000 --- a/packages/migrate/src/cli.ts +++ /dev/null @@ -1,117 +0,0 @@ -#!/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 deleted file mode 100644 index 84d9169d1..000000000 --- a/packages/migrate/src/migrate.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -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.configs.get(fixture.liveWorktree)).toEqual(paseo.configs.get(fixture.repo)); - expect(paseo.createdCheckouts).toEqual([ - { rootPath: fixture.repo, refName: "create-branch", directoryName: "missing-create" }, - ]); - expect(paseo.configs.get(path.join(fixture.repo, ".paseo", "missing-create"))).toEqual( - paseo.configs.get(fixture.repo), - ); - 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(3); - 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(3); - 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 deleted file mode 100644 index 71a964a9f..000000000 --- a/packages/migrate/src/migrate.ts +++ /dev/null @@ -1,223 +0,0 @@ -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); - let config = project.config; - if (context.dryRun) planProject(project, context); - else { - const appliedConfig = await applyProject(project, context); - if (!appliedConfig) return; - config = appliedConfig; - } - await migrateWorkspaces(project, config, 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 null; - } - try { - return await applyConfig(project.rootPath, project.config, context); - } catch (error) { - context.emitNotice(applyFailure("project-config-apply-failed", project.rootPath, error)); - return project.config ?? {}; - } -} - -async function migrateWorkspaces( - project: MigrationProject, - config: PaseoConfigRaw | null, - context: MigrationContext, -): Promise { - for (const workspace of project.workspaces) { - for (const notice of workspace.notices) context.emitNotice(notice); - if (context.dryRun) planWorkspace(project, workspace, context); - else await applyWorkspace(project, config, workspace, context); - } -} - -function planWorkspace( - project: MigrationProject, - 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}.` }); - if (project.config) { - context.stats.configs += 1; - context.output({ - level: "info", - message: `Would merge supported project config for ${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( - project: MigrationProject, - config: PaseoConfigRaw | null, - workspace: MigrationWorkspace, - context: MigrationContext, -): Promise { - try { - if (workspace.disposition === "adopt" && workspace.path) { - await context.paseo.openCheckout(workspace.path); - await applyConfig(workspace.path, config, context); - 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: project.rootPath, - refName: workspace.branch, - directoryName: workspace.directoryName, - }); - await applyConfig(ensured.path, config, context); - 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)); - } -} - -async function applyConfig( - rootPath: string, - imported: PaseoConfigRaw | null, - context: MigrationContext, -): Promise { - const current = await context.paseo.readProjectConfig(rootPath); - const merged = mergeExistingConfig(imported, current.config); - if (isDeepStrictEqual(merged, current.config ?? {})) { - context.output({ level: "info", message: `Project config already current for ${rootPath}.` }); - return current.config ?? {}; - } - await context.paseo.writeProjectConfig({ - rootPath, - config: merged, - expectedRevision: current.revision, - }); - context.stats.configs += 1; - context.output({ level: "info", message: `Updated project config for ${rootPath}.` }); - return merged; -} - -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 deleted file mode 100644 index 500a480cf..000000000 --- a/packages/migrate/src/output.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 61792da70..000000000 --- a/packages/migrate/src/paseo.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index c678817c0..000000000 --- a/packages/migrate/src/sources/conductor/database.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { cpSync, 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 { 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("refuses a database with pending WAL changes", async () => { - const databasePath = temporaryDatabase(); - cpSync(fixturePath, databasePath); - writeFileSync(`${databasePath}-wal`, "pending"); - - await expect(readConductorCatalog(databasePath)).rejects.toThrow( - "Quit Conductor before migrating", - ); -}); - -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.", - }, - ]); -}); - -test("normalizes fully qualified local workspace branches", async () => { - const databasePath = temporaryDatabase(); - cpSync(fixturePath, databasePath); - const database = await openFixtureDatabase(databasePath); - database.run("UPDATE workspaces SET branch = 'refs/heads/feature' WHERE branch IS NOT NULL"); - saveFixtureDatabase(databasePath, database); - - const branches = (await readConductorCatalog(databasePath)).workspaces - .map((workspace) => workspace.branch) - .filter((branch): branch is string => branch !== null); - - expect(branches).toContain("feature"); - expect(branches.some((branch) => branch.startsWith("refs/heads/"))).toBe(false); -}); - -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 deleted file mode 100644 index cdbdc6bb1..000000000 --- a/packages/migrate/src/sources/conductor/database.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { existsSync, readFileSync, statSync } 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 walPath = `${databasePath}-wal`; - if (existsSync(walPath) && statSync(walPath).size > 0) { - throw new UnsupportedConductorDatabaseError( - "Conductor has pending database changes. Quit Conductor before migrating.", - ); - } - 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); - const branch = optionalString(row.branch); - return { - id: stringColumn(row, "id"), - repoId: stringColumn(row, "repo_id"), - branch: branch?.replace(/^refs\/heads\//, "") ?? null, - 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 deleted file mode 100644 index 73cb92048..000000000 --- a/packages/migrate/src/sources/conductor/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index ea2c08ef1..000000000 --- a/packages/migrate/src/sources/conductor/inspect.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -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".', - }, - ], - }); -}); - -test("does not adopt an existing worktree on a different branch", () => { - const repo = createRepository("wrong-worktree-branch", null); - execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { - cwd: repo, - env: { - ...process.env, - GIT_AUTHOR_NAME: "Paseo Test", - GIT_AUTHOR_EMAIL: "test@paseo.local", - GIT_COMMITTER_NAME: "Paseo Test", - GIT_COMMITTER_EMAIL: "test@paseo.local", - }, - }); - execFileSync("git", ["branch", "recorded-branch"], { cwd: repo }); - - const inspected = inspectCatalog({ - repos: [repoRecord("repo", repo)], - workspaces: [ - { - id: "workspace-wrong-branch", - repoId: "repo", - branch: "recorded-branch", - state: "ready", - path: repo, - archiveCommit: null, - }, - ], - }); - - expect(inspected.projects[0]?.workspaces[0]).toMatchObject({ - sourceId: "workspace-wrong-branch", - disposition: "invalid", - notices: [{ code: "invalid-worktree" }], - }); -}); - -test("does not recreate a missing workspace from a tag", () => { - const repo = createRepository("tag-only-workspace", null); - execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { - cwd: repo, - env: { - ...process.env, - GIT_AUTHOR_NAME: "Paseo Test", - GIT_AUTHOR_EMAIL: "test@paseo.local", - GIT_COMMITTER_NAME: "Paseo Test", - GIT_COMMITTER_EMAIL: "test@paseo.local", - }, - }); - execFileSync("git", ["tag", "release"], { cwd: repo }); - - const inspected = inspectCatalog({ - repos: [repoRecord("repo", repo)], - workspaces: [ - { - id: "workspace-tag-only", - repoId: "repo", - branch: "release", - state: "ready", - path: null, - archiveCommit: null, - }, - ], - }); - - expect(inspected.projects[0]?.workspaces[0]).toMatchObject({ - sourceId: "workspace-tag-only", - disposition: "missing-ref", - notices: [{ code: "missing-workspace-ref" }], - }); -}); - -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 deleted file mode 100644 index c085543b9..000000000 --- a/packages/migrate/src/sources/conductor/inspect.ts +++ /dev/null @@ -1,250 +0,0 @@ -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, workspace.branch); - 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", - workspace.branch - ? `Skipped ${workspace.path}: not linked to ${repo.rootPath} on recorded branch ${workspace.branch}.` - : `Skipped ${workspace.path}: not linked to ${repo.rootPath}.`, - ), - ], - }; - } - - if (workspace.branch && localBranchExists(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, - expectedBranch: string | null, -): boolean { - try { - const rootCommon = resolveGitPath(rootPath, git(rootPath, ["rev-parse", "--git-common-dir"])); - const workspaceCommon = resolveGitPath( - workspacePath, - git(workspacePath, ["rev-parse", "--git-common-dir"]), - ); - if (realpathSync(rootCommon) !== realpathSync(workspaceCommon)) return false; - if (!expectedBranch) return true; - const actualBranch = git(workspacePath, ["symbolic-ref", "--short", "HEAD"]); - return actualBranch === expectedBranch.replace(/^refs\/heads\//, ""); - } 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 localBranchExists(rootPath: string, branch: string): boolean { - const branchName = branch.replace(/^refs\/heads\//, ""); - if (!branchName) return false; - try { - git(rootPath, ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]); - 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 deleted file mode 100644 index 151abe6a3..000000000 --- a/packages/migrate/src/sources/conductor/project-config.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -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" - -[scripts.run.unsupported_substitution] -command = "serve --port \${CONDUCTOR_PORT/3000/3001}" - -[scripts.run.comment] -command = "npm test # $CONDUCTOR_PORT" - -[scripts.run.api] -command = "serve --port $CONDUCTOR_PORT" - -[scripts.run.api_] -command = "serve-other --port $CONDUCTOR_PORT" -`, - ); - - const inspected = inspectConductorProjectConfig(repo); - - expect(inspected.config).toEqual({ - scripts: { - api: { command: "serve --port $PASEO_PORT", type: "service" }, - comment: { command: "npm test # $CONDUCTOR_PORT" }, - 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.", - "scripts.unsupported_substitution: Unsupported Conductor variables: CONDUCTOR_PORT. Command was not imported.", - 'scripts.api_: Service environment name collides with "api" (API).', - "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.", - }); -}); - -test("reports a malformed top-level scripts value", () => { - const repo = emptyRepo("malformed-top-level-scripts"); - writeSettings(repo, 'scripts = "npm run dev"\n'); - - const inspected = inspectConductorProjectConfig(repo); - - expect(inspected.config).toBeNull(); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-malformed", - level: "warning", - message: "scripts: Expected a scripts table.", - }); -}); - -test("preserves literal or escaped Conductor names and rewrites active variables", () => { - const inspected = inspectConductorProjectConfig(emptyRepo("literal-variables"), { - scripts: { - setup: "printf '%s\\n' '$CONDUCTOR_WORKSPACE_PATH'", - run: { - escaped: { command: "printf \\$CONDUCTOR_PORT" }, - escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' }, - bare: { command: "printf CONDUCTOR_PORT" }, - heredoc: { command: "cat <<'EOF'\n$CONDUCTOR_PORT\nEOF" }, - active: { command: "printf \"'$CONDUCTOR_PORT'\"" }, - arithmeticDirect: { command: "serve --port $(( $CONDUCTOR_PORT ))" }, - arithmeticBraced: { command: "serve --port $(( ${CONDUCTOR_PORT} ))" }, - arithmeticOffset: { command: "serve --port $(( CONDUCTOR_PORT + 1 ))" }, - prefixExpansion: { command: "serve --length ${#CONDUCTOR_PORT}" }, - unicodePrefix: { command: 'printf 😀 "$CONDUCTOR_PORT"' }, - commandSubstitution: { command: "printf \"$(printf '$CONDUCTOR_PORT')\"" }, - }, - }, - }); - - expect(inspected.config).toEqual({ - worktree: { setup: "printf '%s\\n' '$CONDUCTOR_WORKSPACE_PATH'" }, - scripts: { - active: { command: "printf \"'$PASEO_PORT'\"", type: "service" }, - arithmeticBraced: { command: "serve --port $(( ${PASEO_PORT} ))", type: "service" }, - arithmeticDirect: { command: "serve --port $(( $PASEO_PORT ))", type: "service" }, - bare: { command: "printf CONDUCTOR_PORT" }, - escaped: { command: "printf \\$CONDUCTOR_PORT" }, - escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' }, - unicodePrefix: { command: 'printf 😀 "$PASEO_PORT"', type: "service" }, - }, - }); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-unsupported", - level: "warning", - message: "scripts.heredoc: Here-document commands are not imported.", - }); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-unsupported", - level: "warning", - message: - "scripts.arithmeticOffset.port_arithmetic: Conductor port arithmetic is not imported because Paseo reserves one service port.", - }); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-unsupported", - level: "warning", - message: - "scripts.prefixExpansion: Unsupported Conductor variables: CONDUCTOR_PORT. Command was not imported.", - }); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-unsupported", - level: "warning", - message: "scripts.commandSubstitution: Command substitutions are not imported.", - }); -}); - -test("skips cwd scripts on Windows instead of emitting POSIX shell syntax", () => { - const repo = emptyRepo("windows-cwd"); - writeSettings( - repo, - ` -[scripts.run.dev] -command = "npm run dev" -[scripts.run.dev.options] -cwd = "apps/web" -`, - ); - - const inspected = inspectConductorProjectConfig(repo, {}, "win32"); - - expect(inspected.config).toBeNull(); - expect(inspected.notices).toContainEqual({ - code: "conductor-setting-unsupported", - level: "warning", - message: "scripts.dev.cwd: Working-directory scripts are not imported on Windows.", - }); -}); - -test("skips variable commands on Windows while preserving ordinary commands", () => { - const inspected = inspectConductorProjectConfig( - emptyRepo("windows-variables"), - { - scripts: { - setup: "cd $CONDUCTOR_WORKSPACE_PATH", - run: { - service: { command: "serve --port $CONDUCTOR_PORT" }, - plain: { command: "npm test" }, - args: { command: "npm", args: ["run", "dev"] }, - }, - }, - }, - "win32", - ); - - expect(inspected.config).toEqual({ scripts: { plain: { command: "npm test" } } }); - expect(inspected.notices.map((notice) => notice.message)).toEqual( - expect.arrayContaining([ - "worktree.setup: Conductor variables use unsupported shell syntax on Windows: CONDUCTOR_WORKSPACE_PATH. Command was not imported.", - "scripts.service: Conductor variables use unsupported shell syntax on Windows: CONDUCTOR_PORT. Command was not imported.", - "scripts.args.args: Script arguments are not imported on Windows.", - ]), - ); -}); - -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 deleted file mode 100644 index 2b5989c0b..000000000 --- a/packages/migrate/src/sources/conductor/project-config.ts +++ /dev/null @@ -1,798 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join, posix, relative } from "node:path"; -import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages"; -import { normalizeServiceEnvName } from "@getpaseo/protocol/service-env-name"; -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?: 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 = {}, - platform: NodeJS.Platform = process.platform, -): { config: PaseoConfigRaw | null; notices: MigrationNotice[] } { - const settings = mergeSettings( - databaseSettings, - loadConductorSettings(discoverSources(repoRoot)), - ); - const config: PaseoConfigRaw = {}; - const notices: MigrationNotice[] = []; - const scripts = isRecord(settings.scripts) ? settings.scripts : null; - - if (settings.scripts !== undefined && !scripts) { - notices.push(malformedSetting("scripts", "Expected a scripts table.")); - } - - mapLifecycle(scripts?.setup, "worktree.setup", "setup", config, notices, platform); - mapLifecycle(scripts?.archive, "worktree.teardown", "teardown", config, notices, platform); - mapRunScripts(scripts?.run, config, notices, platform); - 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: mergeScripts(base.scripts, override.scripts), - 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 mergeScripts(base: unknown, override: unknown): unknown { - if (override !== undefined && !isRecord(override)) return override; - if (override === undefined && !isRecord(base)) return base; - const baseScripts = isRecord(base) ? base : {}; - const overrideScripts = isRecord(override) ? override : {}; - return { - ...baseScripts, - ...overrideScripts, - run: mergeRunScripts(baseScripts.run, overrideScripts.run), - }; -} - -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[], - platform: NodeJS.Platform, -): 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, platform); - if (!command) return; - config.worktree = { ...config.worktree, [target]: command }; -} - -function mapRunScripts( - runConfig: unknown, - config: PaseoConfigRaw, - notices: MigrationNotice[], - platform: NodeJS.Platform, -): void { - if (runConfig === undefined) return; - const services = new Map(); - if (typeof runConfig === "string") { - mapRunScript("run", { command: runConfig }, config, notices, services, platform); - 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, platform); - } -} - -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, - platform: NodeJS.Platform, -): 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.")); - } - if (platform === "win32" && script.args && script.args.length > 0) { - notices.push( - unsupportedSetting(`${key}.args`, "Script arguments are not imported on Windows."), - ); - return; - } - - let command = appendArgs(script.command, script.args ?? []); - if (script.cwd) { - if (platform === "win32") { - notices.push( - unsupportedSetting(`${key}.cwd`, "Working-directory scripts are not imported on Windows."), - ); - return; - } - 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 = normalizeServiceEnvName(scriptId); - const collision = serviceNames.get(environmentName); - if (collision) { - notices.push( - unsupportedSetting( - key, - `Service environment name collides with "${collision}" (${environmentName}).`, - "conductor-setting-collision", - ), - ); - return; - } - } - - const rewritten = rewriteExactCommand( - command, - service ? "run" : "lifecycle", - key, - notices, - platform, - ); - if (!rewritten) return; - if (service) { - const environmentName = normalizeServiceEnvName(scriptId); - serviceNames.set(environmentName, scriptId); - } - 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[], - platform: NodeJS.Platform, -): string | null { - if (containsActiveCommandSubstitution(command)) { - notices.push(unsupportedSetting(key, "Command substitutions are not imported.")); - return null; - } - if (containsActiveHereDocument(command)) { - notices.push(unsupportedSetting(key, "Here-document commands are not imported.")); - return null; - } - const sourceVariables = collectConductorVariables(command); - if (platform === "win32" && sourceVariables.length > 0) { - notices.push( - unsupportedSetting( - key, - `Conductor variables use unsupported shell syntax on Windows: ${sourceVariables.join(", ")}. Command was not imported.`, - ), - ); - return 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 scripts = isRecord(settings.scripts) ? settings.scripts : {}; - const unsupportedValues: Array<[string, unknown, string]> = [ - ["scripts.run_mode", 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", - 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 mask = activeShellMask(command); - const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g"); - return replaceArithmeticVariable( - command.replace(pattern, (match, offset: number) => { - if (mask[offset] !== "$") return match; - return match.startsWith("${") ? `\${${to}` : `$${to}`; - }), - from, - to, - ); -} - -function collectConductorVariables(command: string): string[] { - const names = new Set(); - const mask = activeShellMask(command); - const direct = - /\$\{[#!]?(CONDUCTOR_[A-Za-z0-9_]+)(?=[^A-Za-z0-9_])|\$(CONDUCTOR_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/g; - for (const match of command.matchAll(direct)) { - if (mask[match.index] !== "$") continue; - const name = match[1] ?? match[2]; - if (name) names.add(name); - } - forEachActiveArithmetic(command, (body) => { - for (const match of body.matchAll(/(?:^|[^A-Za-z0-9_])(CONDUCTOR_[A-Za-z0-9_]+)/g)) { - if (match[1]) names.add(match[1]); - } - }); - return [...names].sort(); -} - -function activeShellMask(command: string): string { - const mask = command.split(""); - let quote: "single" | "double" | null = null; - for (let index = 0; index < command.length; index += 1) { - const character = command[index]; - if (quote === "single") { - mask[index] = " "; - if (character === "'") quote = null; - continue; - } - if (character === "\\") { - mask[index] = " "; - if (index + 1 < mask.length) mask[index + 1] = " "; - index += 1; - continue; - } - if ( - quote === null && - character === "#" && - (index === 0 || /[\s;|&()<>]/.test(command[index - 1])) - ) { - while (index < command.length && command[index] !== "\n") { - mask[index] = " "; - index += 1; - } - index -= 1; - continue; - } - if (quote === "double") { - if (character === '"') { - mask[index] = " "; - quote = null; - } - continue; - } - if (character === "'") { - mask[index] = " "; - quote = "single"; - } else if (character === '"') { - mask[index] = " "; - quote = "double"; - } - } - return mask.join(""); -} - -function containsShellVariable(command: string, name: string): boolean { - const mask = activeShellMask(command); - const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`, "g"); - for (const match of command.matchAll(pattern)) { - if (mask[match.index] === "$") return true; - } - return containsArithmeticVariable(command, name); -} - -function replaceArithmeticVariable(command: string, from: string, to: string): string { - const mask = activeShellMask(command); - return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string, offset: number) => { - if (mask.slice(offset, offset + 3) !== "$((") return expression; - 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_])`); - let found = false; - forEachActiveArithmetic(command, (body) => { - if (identifier.test(body)) found = true; - }); - return found; -} - -function containsArithmeticVariableOperation(command: string, name: string): boolean { - const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); - const directVariable = new RegExp(`^(?:${name}|\\$${name}|\\$\\{${name}\\})$`); - let found = false; - forEachActiveArithmetic(command, (value) => { - const body = value.trim(); - if (identifier.test(body) && !directVariable.test(body)) found = true; - }); - return found; -} - -function forEachActiveArithmetic(command: string, visit: (body: string) => void): void { - const mask = activeShellMask(command); - for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { - if (mask.slice(match.index, match.index + 3) === "$((") visit(match[1]); - } -} - -function containsActiveHereDocument(command: string): boolean { - const mask = activeShellMask(command); - for (const match of command.matchAll(/<<-?/g)) { - if (mask.slice(match.index, match.index + match[0].length) === match[0]) return true; - } - return false; -} - -function containsActiveCommandSubstitution(command: string): boolean { - const mask = activeShellMask(command); - for (const match of command.matchAll(/\$\((?!\()/g)) { - if (mask.slice(match.index, match.index + 2) === "$(") return true; - } - for (const match of command.matchAll(/`/g)) { - if (mask[match.index] === "`") 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 deleted file mode 100644 index c13acb378..000000000 --- a/packages/migrate/src/sources/conductor/sqlite.fixture.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index b59d59c82..000000000 --- a/packages/migrate/src/sources/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 58b52ec73..000000000 --- a/packages/migrate/src/types.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 46db0e755..000000000 --- a/packages/migrate/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/service-env-name.ts b/packages/protocol/src/service-env-name.ts deleted file mode 100644 index dfdd0dc81..000000000 --- a/packages/protocol/src/service-env-name.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function normalizeServiceEnvName(scriptName: string): string { - return scriptName - .toUpperCase() - .replace(/[^A-Z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); -} diff --git a/packages/server/package.json b/packages/server/package.json index 57c9166d8..6819673e2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -50,8 +50,7 @@ "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\"", - "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": "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/import-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", @@ -101,7 +100,6 @@ "zod": "^4.4.3" }, "devDependencies": { - "@getpaseo/migrate": "0.2.0-beta.1", "@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/import-host-automation.e2e.test.ts similarity index 68% rename from packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts rename to packages/server/src/server/daemon-e2e/import-host-automation.e2e.test.ts index 57e69ed12..7c542702c 100644 --- a/packages/server/src/server/daemon-e2e/migration-host-automation.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/import-host-automation.e2e.test.ts @@ -3,7 +3,6 @@ import { existsSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync 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"; @@ -38,59 +37,6 @@ function isWindowsBusyError(error: unknown): boolean { ); } -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 = migrationSource(repoRoot); - - 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.skipIf(process.platform === "win32")( - "a complete migration recreates an externally deleted checkout", - async () => { - const repoRoot = createRepository(); - const daemon = await createTestPaseoDaemon(); - cleanupDaemons.add(daemon); - const paseo = await connectHostAutomation({ - appVersion: "0.1.110", - clientId: "migration-deleted-checkout-e2e", - env: {}, - host: `127.0.0.1:${daemon.port}`, - }); - cleanupConnections.add(paseo); - const source = migrationSource(repoRoot); - - const first = await migrate({ source, paseo, dryRun: false, output: () => undefined }); - const deletedPath = listFeatureWorktrees(repoRoot)[0]?.path; - if (!deletedPath) throw new Error("First migration did not create the feature checkout"); - rmSync(deletedPath, { recursive: true, force: true }); - expect(existsSync(deletedPath)).toBe(false); - - const second = await migrate({ source, paseo, dryRun: false, output: () => undefined }); - - expect(first.notices).toEqual([]); - expect(second.notices).toEqual([]); - expect(existsSync(deletedPath)).toBe(true); - }, -); - test("a different live checkout continues to protect its branch", async () => { const repoRoot = createRepository(); const existingPath = path.join(path.dirname(repoRoot), "existing-feature"); @@ -99,7 +45,7 @@ test("a different live checkout continues to protect its branch", async () => { cleanupDaemons.add(daemon); const paseo = await connectHostAutomation({ appVersion: "0.1.110", - clientId: "migration-live-protection-e2e", + clientId: "import-live-protection-e2e", env: {}, host: `127.0.0.1:${daemon.port}`, }); @@ -129,7 +75,7 @@ test("ensureCheckout repairs only the requested missing registration", async () cleanupDaemons.add(daemon); const paseo = await connectHostAutomation({ appVersion: "0.1.110", - clientId: "migration-stale-registration-e2e", + clientId: "import-stale-registration-e2e", env: {}, host: `127.0.0.1:${daemon.port}`, }); @@ -157,7 +103,7 @@ test("normalized checkout-name collisions cannot reuse another branch", async () cleanupDaemons.add(daemon); const paseo = await connectHostAutomation({ appVersion: "0.1.110", - clientId: "migration-slug-collision-e2e", + clientId: "import-slug-collision-e2e", env: {}, host: `127.0.0.1:${daemon.port}`, }); @@ -201,7 +147,7 @@ test("the public connector authenticates to a real password-protected daemon and await expect( connectToDaemon({ appVersion: "0.1.110", - clientId: "migration-auth-failure-e2e", + clientId: "import-auth-failure-e2e", env: { PASEO_PASSWORD: "wrong-secret" }, host: `127.0.0.1:${daemon.port}`, timeoutMs: 2_000, @@ -210,7 +156,7 @@ test("the public connector authenticates to a real password-protected daemon and const client = await connectToDaemon({ appVersion: "0.1.110", - clientId: "migration-auth-success-e2e", + clientId: "import-auth-success-e2e", env: { PASEO_PASSWORD: "connector-secret" }, host: `127.0.0.1:${daemon.port}`, }); @@ -230,7 +176,7 @@ test("the public connector uses PORT fallback and skips malformed discovered can const client = await connectToDaemon({ appVersion: "0.1.110", - clientId: "migration-port-fallback-e2e", + clientId: "import-port-fallback-e2e", env: { PASEO_HOME: paseoHome, PORT: String(daemon.port) }, }); cleanupConnections.add(client); @@ -251,7 +197,7 @@ test.skipIf(process.platform === "win32")( const client = await connectToDaemon({ appVersion: "0.1.110", - clientId: "migration-ipc-e2e", + clientId: "import-ipc-e2e", env: {}, host: `unix://${socketPath}`, }); @@ -262,7 +208,7 @@ test.skipIf(process.platform === "win32")( ); function createRepository(): string { - const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-migration-daemon-"))); + const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-import-daemon-"))); cleanupPaths.add(parent); const repoRoot = path.join(parent, "repo"); execFileSync("git", ["init", "-b", "main", repoRoot]); @@ -276,46 +222,3 @@ function createRepository(): string { execFileSync("git", ["branch", "feature"], { cwd: repoRoot }); return realpathSync(repoRoot); } - -function migrationSource(repoRoot: string) { - return { - 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: [], - }, - ], - }, - ], - }), - }; -} - -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/workspace-service-env.ts b/packages/server/src/server/workspace-service-env.ts index 3c42b6551..e7a486294 100644 --- a/packages/server/src/server/workspace-service-env.ts +++ b/packages/server/src/server/workspace-service-env.ts @@ -1,7 +1,4 @@ import { projectServiceProxyUrls } from "./service-proxy.js"; -import { normalizeServiceEnvName } from "@getpaseo/protocol/service-env-name"; - -export { normalizeServiceEnvName } from "@getpaseo/protocol/service-env-name"; export interface WorkspaceServicePeer { scriptName: string; @@ -18,6 +15,13 @@ export interface BuildWorkspaceServiceEnvOptions { peers: readonly WorkspaceServicePeer[]; } +export function normalizeServiceEnvName(scriptName: string): string { + return scriptName + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + export function buildWorkspaceServiceEnv( options: BuildWorkspaceServiceEnvOptions, ): Record { diff --git a/scripts/verify-migrate-package-contents.mjs b/scripts/verify-migrate-package-contents.mjs deleted file mode 100644 index b175bddb6..000000000 --- a/scripts/verify-migrate-package-contents.mjs +++ /dev/null @@ -1,38 +0,0 @@ -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 }); -}