mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(import): load the import engine as a dependency
This commit is contained in:
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -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",
|
||||
|
||||
11
package.json
11
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",
|
||||
|
||||
@@ -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<ReturnType<typeof createConductorInstallation>>;
|
||||
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<ElectronApplication> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async function openImport(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
async function stopImportHost(): Promise<void> {
|
||||
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<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Migration host ${pid} did not stop.`);
|
||||
throw new Error(`Import host ${pid} did not stop.`);
|
||||
}
|
||||
|
||||
function requiredEnvironment(name: string): string {
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
<ConductorMigration />
|
||||
<ExternalImport source={conductorImportSource} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -165,14 +165,15 @@ export interface DesktopInvokeBridge {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 410 B After Width: | Height: | Size: 410 B |
7
packages/app/src/desktop/imports/conductor.ts
Normal file
7
packages/app/src/desktop/imports/conductor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export const conductorImportSource: ImportSourceDescriptor = {
|
||||
id: "conductor",
|
||||
title: "Conductor",
|
||||
icon: require("./conductor.svg"),
|
||||
};
|
||||
47
packages/app/src/desktop/imports/external-import.tsx
Normal file
47
packages/app/src/desktop/imports/external-import.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<View
|
||||
style={[settingsStyles.row, settingsStyles.rowBorder]}
|
||||
testID={`${source.id}-import-row`}
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Image source={source.icon} style={styles.icon} />
|
||||
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{importFlow.reason ?? t("desktop.integrations.import.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={importFlow.open}
|
||||
disabled={!importFlow.available}
|
||||
testID={`${source.id}-import-open`}
|
||||
>
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
</View>
|
||||
<ImportSheet source={source} visible={importFlow.visible} onClose={importFlow.close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
icon: { width: theme.iconSize.md, height: theme.iconSize.md },
|
||||
}));
|
||||
@@ -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<typeof setTimeout> | 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<MigrationState>({ status: "confirm" });
|
||||
const header = useMemo<SheetHeader>(() => ({ title: source.sheetTitle }), [source.sheetTitle]);
|
||||
const [state, setState] = useState<ImportState>({ status: "confirm" });
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ 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 (
|
||||
<Button onPress={start} testID="migration-confirm">
|
||||
{t("desktop.integrations.migration.actions.import")}
|
||||
<Button onPress={start} testID="import-confirm">
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (state.status === "running") {
|
||||
return <Button disabled>{t("desktop.integrations.migration.actions.importing")}</Button>;
|
||||
return <Button disabled>{t("desktop.integrations.import.actions.importing")}</Button>;
|
||||
}
|
||||
return (
|
||||
<Button onPress={close} testID="migration-done">
|
||||
{t("desktop.integrations.migration.actions.done")}
|
||||
<Button onPress={close} testID="import-done">
|
||||
{t("desktop.integrations.import.actions.done")}
|
||||
</Button>
|
||||
);
|
||||
}, [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" ? (
|
||||
<Text style={styles.copy}>{source.confirmation}</Text>
|
||||
<Text style={styles.copy}>
|
||||
{t("desktop.integrations.import.confirmation", { source: source.title })}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{state.status === "complete" ? (
|
||||
<Text testID="migration-result" style={styles.copy}>
|
||||
<Text testID="import-result" style={styles.copy}>
|
||||
{state.succeeded
|
||||
? t("desktop.integrations.migration.complete")
|
||||
: t("desktop.integrations.migration.failed")}
|
||||
? t("desktop.integrations.import.complete")
|
||||
: t("desktop.integrations.import.failed")}
|
||||
</Text>
|
||||
) : null}
|
||||
{state.status === "failed" ? (
|
||||
<Text testID="migration-error" style={styles.error}>
|
||||
<Text testID="import-error" style={styles.error}>
|
||||
{state.message}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollView style={styles.output}>
|
||||
<Text selectable testID="migration-output" style={styles.outputText}>
|
||||
<Text selectable testID="import-output" style={styles.outputText}>
|
||||
{state.output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -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) => ({
|
||||
@@ -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<MigrationSourceDescriptor>(
|
||||
() => ({
|
||||
...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 (
|
||||
<>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]} testID="conductor-migration-row">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<Image source={source.icon} style={styles.icon} />
|
||||
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>{migration.reason ?? source.description}</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={migration.open}
|
||||
disabled={!migration.available}
|
||||
testID="conductor-migration-open"
|
||||
>
|
||||
{t("desktop.integrations.migration.actions.import")}
|
||||
</Button>
|
||||
</View>
|
||||
<MigrationSheet source={source} visible={migration.visible} onClose={migration.close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
icon: { width: theme.iconSize.md, height: theme.iconSize.md },
|
||||
}));
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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のインストール状態を確認できません。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 安装状态。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<MigrationTarget> => {
|
||||
getTarget: async (): Promise<ImportTarget> => {
|
||||
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;
|
||||
}
|
||||
|
||||
182
packages/desktop/src/integrations/imports/runner.test.ts
Normal file
182
packages/desktop/src/integrations/imports/runner.test.ts
Normal file
@@ -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<ImportResult>;
|
||||
}): 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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
await waitUntil(() => outputs.some((output) => output.type === "status"));
|
||||
}
|
||||
|
||||
async function waitUntil<T>(read: () => T | Promise<T>): Promise<T> {
|
||||
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.");
|
||||
}
|
||||
168
packages/desktop/src/integrations/imports/runner.ts
Normal file
168
packages/desktop/src/integrations/imports/runner.ts
Normal file
@@ -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<void> };
|
||||
|
||||
interface ImportRunnerDependencies {
|
||||
sources: ReadonlyMap<string, ImportSourceInput>;
|
||||
getTarget(): Promise<ImportTarget>;
|
||||
connect(target: ImportTarget): Promise<ConnectedImportHost>;
|
||||
runImport(options: RunImportOptions): Promise<ImportResult>;
|
||||
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<string> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, readonly string[]>;
|
||||
getTarget(): Promise<MigrationTarget>;
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
[scripts]
|
||||
setup = "npm ci --prefer-offline"
|
||||
|
||||
[scripts.run.test]
|
||||
command = "npm test"
|
||||
@@ -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"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "legacy setup",
|
||||
"archive": "legacy teardown",
|
||||
"run": {
|
||||
"test": {
|
||||
"command": "legacy test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<number> {
|
||||
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 <source> [--host <connection>] [--database <path>] [--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<boolean> {
|
||||
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();
|
||||
}
|
||||
@@ -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<string>();
|
||||
readonly configs = new Map<string, PaseoConfigRaw>();
|
||||
readonly openedCheckouts: string[] = [];
|
||||
readonly createdCheckouts: Array<{
|
||||
rootPath: string;
|
||||
refName: string;
|
||||
directoryName: string;
|
||||
}> = [];
|
||||
configWrites = 0;
|
||||
rejectWrites = false;
|
||||
|
||||
constructor(configs: Record<string, PaseoConfigRaw>) {
|
||||
for (const [rootPath, config] of Object.entries(configs)) this.configs.set(rootPath, config);
|
||||
}
|
||||
|
||||
async addProject(rootPath: string): Promise<void> {
|
||||
this.projects.add(rootPath);
|
||||
}
|
||||
|
||||
async openCheckout(checkoutPath: string): Promise<void> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
@@ -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<MigrationResult> {
|
||||
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<void> {
|
||||
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<PaseoConfigRaw | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<PaseoConfigRaw> {
|
||||
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<string, unknown>, override: Record<string, unknown>) {
|
||||
const merged: Record<string, unknown> = { ...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<string, unknown> {
|
||||
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)}` };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { MigrationOutput } from "./types.js";
|
||||
|
||||
export function createStreamingOutput(streams: {
|
||||
stdout: Pick<NodeJS.WriteStream, "write">;
|
||||
stderr: Pick<NodeJS.WriteStream, "write">;
|
||||
}): MigrationOutput {
|
||||
return (event) => {
|
||||
const stream = event.level === "error" ? streams.stderr : streams.stdout;
|
||||
stream.write(`${event.level.toUpperCase()}: ${event.message}\n`);
|
||||
};
|
||||
}
|
||||
@@ -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<PaseoMigrationPort & { close(): Promise<void> }> {
|
||||
return connectHostAutomation({ appVersion: input.version, host: input.host });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<ConductorCatalog> {
|
||||
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<string, unknown>[] {
|
||||
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<string> {
|
||||
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<string>, 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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
} 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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new UnsupportedConductorDatabaseError("Conductor database returned an invalid row.");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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<string, ConductorWorkspaceRecord[]>();
|
||||
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<typeof inspectConductorProjectConfig>;
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown> = { ...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<string, unknown> = { ...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<string, string>();
|
||||
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<string, unknown>,
|
||||
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<string, string>,
|
||||
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<string, unknown>,
|
||||
known: Set<string>,
|
||||
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<string>();
|
||||
for (const key of ["environment_variables", "environment_variables_forward"] as const) {
|
||||
collectNames(settings[key], names);
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
function collectNames(value: unknown, names: Set<string>): 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<string>();
|
||||
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<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -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<SqlJsStatic>;
|
||||
const sqlite = initSqlJs({
|
||||
locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm"),
|
||||
});
|
||||
|
||||
export async function openFixtureDatabase(databasePath?: string): Promise<Database> {
|
||||
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();
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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<MigrationInventory>;
|
||||
}
|
||||
|
||||
export interface PaseoMigrationPort {
|
||||
addProject(rootPath: string): Promise<void>;
|
||||
openCheckout(path: string): Promise<void>;
|
||||
readProjectConfig(rootPath: string): Promise<{
|
||||
config: PaseoConfigRaw | null;
|
||||
revision: PaseoConfigRevision | null;
|
||||
}>;
|
||||
writeProjectConfig(input: {
|
||||
rootPath: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
}): Promise<void>;
|
||||
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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function normalizeServiceEnvName(scriptName: string): string {
|
||||
return scriptName
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user