refactor(import): load the import engine as a dependency

This commit is contained in:
Mohamed Boudra
2026-07-20 15:05:57 +02:00
parent 194822b939
commit 9519c5ba99
61 changed files with 656 additions and 3811 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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>
);

View File

@@ -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 {

View File

Before

Width:  |  Height:  |  Size: 410 B

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -0,0 +1,7 @@
import type { ImportSourceDescriptor } from "./import-sheet";
export const conductorImportSource: ImportSourceDescriptor = {
id: "conductor",
title: "Conductor",
icon: require("./conductor.svg"),
};

View 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 },
}));

View File

@@ -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) => ({

View File

@@ -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 },
}));

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 limportation…",
"unsupported-source": "Cette source dimportation est indisponible.",
"host-not-running": "Démarrez lhôte géré par Desktop avant limportation.",
"nonlocal-host": "Limportation est indisponible pour un hôte distant.",
"password-protected":
"Limportation est indisponible tant que lhôte local est protégé par mot de passe.",
"host-version-mismatch": "Mettez à jour lhôte géré par Desktop avant limportation.",
"migrator-version-mismatch":
"Le migrateur intégré ne correspond pas à cette version de Desktop.",
unavailable: "La migration Desktop est indisponible.",
unavailable: "Limportation Desktop est indisponible.",
},
actions: { import: "Importer", importing: "Importation…", done: "Terminé" },
unavailable: "La migration depuis lapplication de bureau est indisponible.",
unavailable: "Limportation depuis lapplication de bureau est indisponible.",
complete: "Importation terminée.",
failed: "Échec de limportation.",
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.",

View File

@@ -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のインストール状態を確認できません。",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 安装状态。",

View File

@@ -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);

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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;
}

View 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.");
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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),
};
}

View File

@@ -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;
}

View File

@@ -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).

View File

@@ -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: {

View File

@@ -1,5 +0,0 @@
[scripts]
setup = "npm ci --prefer-offline"
[scripts.run.test]
command = "npm test"

View File

@@ -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"

View File

@@ -1,11 +0,0 @@
{
"scripts": {
"setup": "legacy setup",
"archive": "legacy teardown",
"run": {
"test": {
"command": "legacy test"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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,
};
}

View File

@@ -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();
}

View File

@@ -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));
}

View File

@@ -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)}` };
}

View File

@@ -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`);
};
}

View File

@@ -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 });
}

View File

@@ -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");
}

View File

@@ -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>;
}

View File

@@ -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()),
);
},
};
}

View File

@@ -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: [],
};
}

View File

@@ -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 };
}

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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();
}

View File

@@ -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}`);
}

View File

@@ -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;

View File

@@ -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"]
}

View File

@@ -1,6 +0,0 @@
export function normalizeServiceEnvName(scriptName: string): string {
return scriptName
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}

View File

@@ -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",

View File

@@ -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 }] : [];
});
}

View File

@@ -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> {

View File

@@ -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 });
}