Compare commits

...

41 Commits

Author SHA1 Message Date
Mohamed Boudra
a2e8a44848 fix(desktop): recognize managed import targets
Desktop ownership is explicit application state. Avoid inferring it from host addresses or launching a second status probe, while retaining validation when an import starts.
2026-07-23 20:36:22 +02:00
Mohamed Boudra
81bffbd01a fix(import): harden the desktop import flow 2026-07-20 15:16:03 +02:00
Mohamed Boudra
9519c5ba99 refactor(import): load the import engine as a dependency 2026-07-20 15:05:57 +02:00
Mohamed Boudra
194822b939 fix(server): authorize active workspace config 2026-07-19 07:28:45 +02:00
Mohamed Boudra
2505075beb fix(migrate): reject nested command substitutions 2026-07-19 06:30:11 +02:00
Mohamed Boudra
0c3c61be94 fix(migrate): refresh managed checkout config 2026-07-19 06:09:20 +02:00
Mohamed Boudra
7046b118e9 fix(migrate): preserve adopted workspace config 2026-07-19 05:39:59 +02:00
Mohamed Boudra
3fed2f2cd9 fix(migrate): validate source refs and expansions 2026-07-19 05:02:59 +02:00
Mohamed Boudra
234f0d9b75 test(migrate): cover deleted checkout recreation 2026-07-19 04:28:05 +02:00
Mohamed Boudra
548e15c177 fix(migrate): preserve direct port arithmetic 2026-07-19 03:53:07 +02:00
Mohamed Boudra
6240b2d17c test(server): tolerate delayed Windows handle release 2026-07-19 03:39:32 +02:00
Mohamed Boudra
4e99e4c18c fix(server): release workspace watchers on shutdown 2026-07-19 03:06:48 +02:00
Mohamed Boudra
2f07c5918f test(migrate): compare checkout identities portably 2026-07-19 02:47:23 +02:00
Mohamed Boudra
3e6061b169 fix(client): normalize checkout branch refs 2026-07-19 02:27:46 +02:00
Mohamed Boudra
4ed944db2f fix(migrate): validate live source state 2026-07-19 02:12:29 +02:00
Mohamed Boudra
af244d0f02 fix(migrate): share service command semantics 2026-07-19 02:01:39 +02:00
Mohamed Boudra
f2124f8cf9 fix(migrate): normalize source references 2026-07-19 01:50:04 +02:00
Mohamed Boudra
152cdcabad fix(migrate): preserve Windows shell semantics 2026-07-19 01:39:15 +02:00
Mohamed Boudra
44da3cdf05 fix(migrate): preserve consistent source state 2026-07-19 01:33:48 +02:00
Mohamed Boudra
a8c6c2f4bc fix(migrate): gate automatic source discovery 2026-07-19 01:21:02 +02:00
Mohamed Boudra
faccbaae59 fix(migrate): validate workspace connection targets 2026-07-19 01:10:18 +02:00
Mohamed Boudra
e61c06706c fix(migrate): preserve source command semantics 2026-07-19 01:03:00 +02:00
Mohamed Boudra
c54ddfd07f fix(migrate): preserve checkout and shell semantics 2026-07-19 00:48:41 +02:00
Mohamed Boudra
ddaaaa27be fix(desktop): flush migration output before completion 2026-07-19 00:29:50 +02:00
Mohamed Boudra
fbd02437dc Merge main into project migration branch 2026-07-19 00:28:33 +02:00
Mohamed Boudra
b447a650a7 feat(desktop): migrate existing projects and worktrees 2026-07-19 00:22:17 +02:00
Mohamed Boudra
4d2acfbeb5 fix(server): reject imported port offsets 2026-07-17 18:28:10 +02:00
Mohamed Boudra
d6bc201f46 fix(app): refresh import availability previews 2026-07-17 18:10:55 +02:00
Mohamed Boudra
5e75e463ec fix(settings): expose remaining import errors 2026-07-17 17:47:16 +02:00
Mohamed Boudra
35d4ae201a fix(settings): tighten conductor service imports 2026-07-17 17:21:23 +02:00
Mohamed Boudra
b93d326fb2 fix(settings): preserve conductor migration context 2026-07-17 17:00:30 +02:00
Mohamed Boudra
9505e43925 fix(settings): report remaining import gaps 2026-07-17 16:42:07 +02:00
Mohamed Boudra
2f0e3451de fix(settings): wait for import preview before setup callout 2026-07-17 16:22:10 +02:00
Mohamed Boudra
28cac5859a fix(settings): keep project imports current and complete 2026-07-17 15:56:04 +02:00
Mohamed Boudra
59d9a25a0d fix(settings): handle remaining import edge cases 2026-07-17 15:32:27 +02:00
Mohamed Boudra
60462337b6 fix(settings): close project import edge cases 2026-07-17 15:11:04 +02:00
Mohamed Boudra
f44ef807ef fix(settings): harden conductor imports 2026-07-17 14:48:54 +02:00
Mohamed Boudra
f030ddefb1 refactor(settings): make project imports pluggable 2026-07-17 14:42:02 +02:00
Mohamed Boudra
f7a6548b68 fix(app): refresh stale config import apply 2026-07-17 11:33:56 +02:00
Mohamed Boudra
ab9c9b96a7 fix(server): normalize conductor import paths 2026-07-17 11:31:52 +02:00
Mohamed Boudra
8fe11e3bb0 feat(settings): import existing project setup 2026-07-17 11:15:10 +02:00
48 changed files with 2195 additions and 426 deletions

View File

@@ -200,6 +200,41 @@ jobs:
if-no-files-found: ignore
retention-days: 7
import-electron:
runs-on: macos-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build app and server dependencies
run: |
npm run build:app-deps
npm run build:server
- name: Build Desktop main
run: npm run build:main --workspace=@getpaseo/desktop
- name: Run product import behavior
run: npm run test:import-electron --workspace=@getpaseo/desktop
- name: Upload import behavior diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: import-electron-macos
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
app-tests:
runs-on: ubuntu-latest
env:

View File

@@ -91,7 +91,19 @@ it does not depend on the server.
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
facade. App and CLI may import the low-level driver from
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
code imports from `@getpaseo/client`.
code imports from `@getpaseo/client`. Node automation uses
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
selection, and the capability-gated host automation facade shared by the CLI and
published import tools.
### External project import
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,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
Desktop 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: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.
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.

31
package-lock.json generated
View File

@@ -31726,7 +31726,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
@@ -31891,6 +31890,12 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/srvx": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
@@ -36436,10 +36441,12 @@
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/ws": "^8.5.14",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}
@@ -36450,6 +36457,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/import": "0.1.0-beta.2",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -36457,6 +36465,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",
@@ -36467,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",

View File

@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import {
_electron as electron,
expect,
test,
type ElectronApplication,
type Page,
} from "@playwright/test";
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
test.setTimeout(180_000);
const repoRoot = path.resolve(__dirname, "../../..");
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
let electronApp: ElectronApplication;
test.beforeAll(async () => {
installation = await createConductorInstallation();
});
test.afterAll(async () => {
await electronApp?.close();
rmSync(installation.root, { recursive: true, force: true });
});
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
electronApp = await launchProduct();
const page = await electronApp.firstWindow();
await openIntegrations(page);
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
await openImport(page);
await expect(
page.getByText("Paseo will register valid repositories", { exact: false }),
).toBeVisible();
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("import-output")).toContainText("Import summary:");
await page.getByTestId("import-done").click();
writeFileSync(installation.databasePath, "not a sqlite database");
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("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-import-row");
await expect(row).toContainText("Start the Desktop-managed host before importing.");
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
});
async function launchProduct(): Promise<ElectronApplication> {
const metroPort = requiredEnvironment("E2E_METRO_PORT");
return electron.launch({
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
env: {
...process.env,
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
HOME: installation.home,
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
},
});
}
async function openIntegrations(page: Page): Promise<void> {
const settings = page.getByRole("button", { name: "Settings", exact: true });
await expect(settings).toBeVisible({ timeout: 90_000 });
await settings.click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await page.getByRole("button", { name: "Integrations", exact: true }).click();
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
}
async function openImport(page: Page): Promise<void> {
const button = page
.getByTestId("conductor-import-row")
.getByRole("button", { name: "Import", exact: true });
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("import-sheet")).toBeVisible();
}
async function createConductorInstallation(): Promise<{
root: string;
home: string;
repo: string;
databasePath: string;
}> {
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 });
execFileSync("git", ["init", "-b", "main", repo]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
writeFileSync(path.join(repo, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repo });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repo,
});
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
mkdirSync(databaseDirectory, { recursive: true });
const databasePath = path.join(databaseDirectory, "conductor.db");
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
execFileSync(
process.execPath,
[
"-e",
`const fs = require("node:fs");
const initSqlJs = require("sql.js");
(async () => {
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
const databasePath = process.argv[1];
const repo = process.argv[2];
const database = new SQL.Database(fs.readFileSync(databasePath));
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
database.run("DELETE FROM workspaces");
fs.writeFileSync(databasePath, database.export());
database.close();
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
databasePath,
repo,
],
{ cwd: repoRoot },
);
return { root, home, repo: realpathSync(repo), databasePath };
}
async function 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");
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
await new Promise((resolve) => setTimeout(resolve, 100));
} catch {
return;
}
}
throw new Error(`Import host ${pid} did not stop.`);
}
function requiredEnvironment(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}

View File

@@ -709,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_LISTEN:
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {

View File

@@ -23,6 +23,7 @@
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
"test:e2e: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

@@ -27,7 +27,10 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
testIgnore:
process.env.E2E_DESKTOP_RUNTIME === "1"
? ["**/*.real.spec.ts"]
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{

View File

@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
scrollable?: boolean;
presentation?: "push" | "replace";
/** Whether gestures may dismiss the compact bottom sheet. */
dismissible?: boolean;
}
export function AdaptiveModalSheet({
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
desktopMaxWidth,
scrollable = true,
presentation,
dismissible = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
onDismiss={handleDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
enablePanDownToClose={dismissible}
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"

View File

@@ -16,6 +16,8 @@ import {
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
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";
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
onUninstall={handleUninstallSkills}
/>
</View>
<ExternalImport source={conductorImportSource} />
</View>
</SettingsSection>
);

View File

@@ -17,7 +17,11 @@ interface DaemonStatusData {
logs: DesktopDaemonLogs;
}
export function useDaemonStatus() {
interface UseDaemonStatusOptions {
refetchOnMount?: boolean | "always";
}
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnMount: options.refetchOnMount ?? "always",
retry: false,
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);

View File

@@ -1,8 +1,6 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { getIsElectron } from "@/constants/platform";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
const isElectron = getIsElectron();
const localDaemon = useLocalDaemonServerIdState();
const hosts = useHosts();
const { settings, updateSettings } = useDesktopSettings();
const { data, setStatus, refetch } = useDaemonStatus();
const { enable } = useBuiltInDaemonManagement({
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
refreshStatus: refetch,
});
const isLocalhostConfigured =
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId);
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
const visible = isElectron && data?.status.desktopManaged === false;
const onPress = useCallback(() => {
void (async () => {

View File

@@ -165,6 +165,19 @@ export interface DesktopInvokeBridge {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
}
export type DesktopImportOutput =
| {
runId: string;
type: "event";
event: { level: "info" | "warning" | "error"; message: string };
}
| { runId: string; type: "status"; succeeded: boolean };
export interface DesktopImportsBridge {
run?: (input: { source: string }) => Promise<{ runId: string }>;
onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void;
}
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
@@ -178,6 +191,7 @@ export interface DesktopHostBridge {
webUtils?: DesktopWebUtilsBridge;
menu?: DesktopMenuBridge;
browser?: DesktopBrowserBridge;
imports?: DesktopImportsBridge;
}
declare global {

View File

@@ -0,0 +1,116 @@
<svg width="115" height="174" viewBox="0 0 115 174" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="36" width="23" height="29">
<path d="M0.499023 63.6992V37.251H22.373V63.6992H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask0_41_20251)">
<path d="M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask1_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="36" width="24" height="29">
<path d="M18.7979 63.6992V37.251H40.6719V63.6992H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask1_41_20251)">
<path d="M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask2_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="73" width="23" height="28">
<path d="M0.499023 100.297V73.8486H22.373V100.297H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask2_41_20251)">
<path d="M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask3_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="73" width="24" height="28">
<path d="M18.7979 100.297V73.8486H40.6719V100.297H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask3_41_20251)">
<path d="M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask4_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="109" width="23" height="29">
<path d="M0.499023 136.896V110.447H22.373V136.896H0.499023Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask4_41_20251)">
<path d="M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask5_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="109" width="24" height="29">
<path d="M18.7979 136.896V110.447H40.6719V136.896H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask5_41_20251)">
<path d="M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask6_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="146" width="24" height="28">
<path d="M18.7979 173.493V147.045H40.6719V173.493H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask6_41_20251)">
<path d="M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask7_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="146" width="24" height="28">
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask7_41_20251)">
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask8_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="146" width="24" height="28">
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask8_41_20251)">
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask9_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="146" width="24" height="28">
<path d="M73.6963 173.493V147.045H95.5703V173.493H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask9_41_20251)">
<path d="M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask10_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="109" width="24" height="29">
<path d="M73.6963 136.896V110.447H95.5703V136.896H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask10_41_20251)">
<path d="M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask11_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="109" width="24" height="29">
<path d="M91.9932 136.896V110.447H113.867V136.896H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask11_41_20251)">
<path d="M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask12_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="0" width="24" height="28">
<path d="M18.7979 27.1006V0.652344H40.6719V27.1006H18.7979Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask12_41_20251)">
<path d="M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask13_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="0" width="24" height="28">
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask13_41_20251)">
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask14_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="0" width="24" height="28">
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask14_41_20251)">
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask15_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="0" width="24" height="28">
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask15_41_20251)">
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask16_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="0" width="24" height="28">
<path d="M91.9932 27.1006V0.652344H113.867V27.1006H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask16_41_20251)">
<path d="M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask17_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="36" width="24" height="29">
<path d="M73.6963 63.6992V37.251H95.5703V63.6992H73.6963Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask17_41_20251)">
<path d="M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
<mask id="mask18_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="36" width="24" height="29">
<path d="M91.9932 63.6992V37.251H113.867V63.6992H91.9932Z" fill="white" stroke="white"/>
</mask>
<g mask="url(#mask18_41_20251)">
<path d="M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1,8 @@
import type { ImportSourceDescriptor } from "./import-sheet";
export const conductorImportSource: ImportSourceDescriptor = {
id: "conductor",
title: "Conductor",
// Official light letter mark from https://www.conductor.build/brandkit.
icon: require("./conductor.svg"),
};

View File

@@ -0,0 +1,71 @@
import { useCallback, useState } from "react";
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 { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { settingsStyles } from "@/styles/settings";
import { ImportSheet, type ImportSourceDescriptor } from "./import-sheet";
export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
const { t } = useTranslation();
const { data } = useDaemonStatus({ refetchOnMount: false });
const desktopManaged = data?.status.desktopManaged;
const [visible, setVisible] = useState(false);
const open = useCallback(() => setVisible(true), []);
const close = useCallback(() => setVisible(false), []);
if (!isElectronRuntimeMac()) return null;
const available = desktopManaged === true;
let reason: string | null = null;
if (desktopManaged === undefined) {
reason = t("desktop.integrations.import.availability.checking");
} else if (!available) {
reason = t("desktop.integrations.import.availability.host-not-running");
}
return (
<>
<View
style={[settingsStyles.row, settingsStyles.rowBorder]}
testID={`${source.id}-import-row`}
>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<View style={styles.iconFrame}>
<Image source={source.icon} style={styles.icon} resizeMode="contain" />
</View>
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{reason ?? t("desktop.integrations.import.description")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={open}
disabled={!available}
testID={`${source.id}-import-open`}
>
{t("desktop.integrations.import.actions.import")}
</Button>
</View>
<ImportSheet source={source} visible={visible} onClose={close} />
</>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
iconFrame: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
backgroundColor: "#282423",
},
icon: { width: 12, height: 18 },
}));

View File

@@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ImageSourcePropType } from "react-native";
import { ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { getDesktopHost, type DesktopImportOutput } from "@/desktop/host";
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 ImportSourceDescriptor {
id: string;
icon: ImageSourcePropType;
title: string;
}
export function ImportSheet({
source,
visible,
onClose,
}: {
source: ImportSourceDescriptor;
visible: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
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]);
useEffect(() => {
if (!visible) setState({ status: "confirm" });
}, [visible]);
const start = useCallback(async () => {
const bridge = getDesktopHost()?.imports;
if (!bridge?.run || !bridge.onOutput) {
setState({
status: "failed",
message: t("desktop.integrations.import.unavailable"),
output: "",
});
return;
}
setState({ status: "running", runId: null, output: "" });
let runId: string | null = null;
const unsubscribe = bridge.onOutput((event: DesktopImportOutput) => {
if (runId && event.runId !== runId) return;
setState((current) => reduceOutput(current, event));
if (event.type === "status") unsubscribe();
});
try {
const started = await bridge.run({ source: source.id });
runId = started.runId;
setState((current) =>
current.status === "running" ? { ...current, runId: started.runId } : current,
);
} catch (error) {
unsubscribe();
setState({
status: "failed",
message: error instanceof Error ? error.message : String(error),
output: "",
});
}
}, [source.id, t]);
const footer = useMemo(() => {
if (state.status === "confirm") {
return (
<Button onPress={start} testID="import-confirm">
{t("desktop.integrations.import.actions.import")}
</Button>
);
}
if (state.status === "running") {
return <Button disabled>{t("desktop.integrations.import.actions.importing")}</Button>;
}
return (
<Button onPress={close} testID="import-done">
{t("desktop.integrations.import.actions.done")}
</Button>
);
}, [close, start, state.status, t]);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={close}
footer={footer}
desktopMaxWidth={640}
testID="import-sheet"
dismissible={state.status !== "running"}
>
{state.status === "confirm" ? (
<Text style={styles.copy}>
{t("desktop.integrations.import.confirmation", { source: source.title })}
</Text>
) : (
<View style={styles.content}>
{state.status === "complete" ? (
<Text testID="import-result" style={styles.copy}>
{state.succeeded
? t("desktop.integrations.import.complete")
: t("desktop.integrations.import.failed")}
</Text>
) : null}
{state.status === "failed" ? (
<Text testID="import-error" style={styles.error}>
{state.message}
</Text>
) : null}
<ScrollView style={styles.output}>
<Text selectable testID="import-output" style={styles.outputText}>
{state.output}
</Text>
</ScrollView>
</View>
)}
</AdaptiveModalSheet>
);
}
function reduceOutput(state: ImportState, event: DesktopImportOutput): ImportState {
if (state.status !== "running") return state;
if (event.type === "status") {
return { status: "complete", succeeded: event.succeeded, output: state.output };
}
const line = `${event.event.level.toUpperCase()}: ${event.event.message}\n`;
return { ...state, output: `${state.output}${line}` };
}
const styles = StyleSheet.create((theme) => ({
content: { gap: theme.spacing[3] },
copy: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm },
output: {
minHeight: 180,
maxHeight: 360,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
padding: theme.spacing[3],
},
outputText: { color: theme.colors.foregroundMuted, fontFamily: "monospace", fontSize: 12 },
}));

View File

@@ -1117,6 +1117,20 @@ export const ar: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "جارٍ التحقق من توفّر الاستيراد…",
"host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
},
actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" },
unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
complete: "اكتمل الاستيراد.",
failed: "فشل الاستيراد.",
description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.",
sheetTitle: "الاستيراد من {{source}}",
confirmation:
"سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات {{source}}.",
},
cli: {
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
installFailed: "غير قادر على تثبيت PaseoCLI.",

View File

@@ -1128,6 +1128,20 @@ export const en = {
},
},
integrations: {
import: {
availability: {
checking: "Checking import availability…",
"host-not-running": "Start the Desktop-managed host before importing.",
},
actions: { import: "Import", importing: "Importing…", done: "Done" },
unavailable: "Desktop import is unavailable.",
complete: "Import complete.",
failed: "Import failed.",
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.",
installFailed: "Unable to install the Paseo CLI.",

View File

@@ -1157,6 +1157,20 @@ export const es: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "Comprobando la disponibilidad de la importación…",
"host-not-running": "Inicia el host gestionado por Desktop antes de importar.",
},
actions: { import: "Importar", importing: "Importando…", done: "Listo" },
unavailable: "La importación de escritorio no está disponible.",
complete: "Importación completada.",
failed: "La importación ha fallado.",
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.",
installFailed: "No se puede instalar elPaseoCLI.",

View File

@@ -1159,6 +1159,20 @@ export const fr: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "Vérification de la disponibilité de limportation…",
"host-not-running": "Démarrez lhôte géré par Desktop avant limportation.",
},
actions: { import: "Importer", importing: "Importation…", done: "Terminé" },
unavailable: "Limportation depuis lapplication de bureau est indisponible.",
complete: "Importation terminée.",
failed: "Échec de limportation.",
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.",
installFailed: "Impossible d'installer lePaseoCLI.",

View File

@@ -1132,6 +1132,20 @@ export const ja: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "インポートが利用可能か確認しています…",
"host-not-running": "インポートする前にDesktop管理ホストを起動してください。",
},
actions: { import: "インポート", importing: "インポート中…", done: "完了" },
unavailable: "デスクトップインポートは利用できません。",
complete: "インポートが完了しました。",
failed: "インポートに失敗しました。",
description: "このMacからプロジェクト、設定、worktreeをインポートします。",
sheetTitle: "{{source}}からインポート",
confirmation:
"Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。{{source}}のデータは変更されません。",
},
cli: {
statusFailed: "CLIのインストール状態を確認できません。",
installFailed: "Paseo CLIをインストールできません。",

View File

@@ -1144,6 +1144,20 @@ export const ptBR: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "Verificando a disponibilidade da importação…",
"host-not-running": "Inicie o host gerenciado pelo Desktop antes de importar.",
},
actions: { import: "Importar", importing: "Importando…", done: "Concluído" },
unavailable: "A importação pelo aplicativo para desktop não está disponível.",
complete: "Importação concluída.",
failed: "Falha na importação.",
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.",
installFailed: "Não foi possível instalar a CLI do Paseo.",

View File

@@ -1147,6 +1147,20 @@ export const ru: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "Проверка доступности импорта…",
"host-not-running": "Запустите управляемый Desktop хост перед импортом.",
},
actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" },
unavailable: "Импорт в настольном приложении недоступен.",
complete: "Импорт завершён.",
failed: "Не удалось выполнить импорт.",
description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.",
sheetTitle: "Импорт из {{source}}",
confirmation:
"Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные {{source}} не изменятся.",
},
cli: {
statusFailed: "Невозможно проверить статус установки CLI.",
installFailed: "Невозможно установить PaseoCLI.",

View File

@@ -1103,6 +1103,20 @@ export const zhCN: TranslationResources = {
},
},
integrations: {
import: {
availability: {
checking: "正在检查导入是否可用…",
"host-not-running": "请先启动由桌面端管理的主机再导入。",
},
actions: { import: "导入", importing: "正在导入…", done: "完成" },
unavailable: "桌面端导入不可用。",
complete: "导入完成。",
failed: "导入失败。",
description: "从这台 Mac 导入项目、设置和工作树。",
sheetTitle: "从 {{source}} 导入",
confirmation:
"Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。{{source}} 数据不会被修改。",
},
cli: {
statusFailed: "无法检查 CLI 安装状态。",
installFailed: "无法安装 Paseo CLI。",

View File

@@ -1,22 +1,21 @@
import { existsSync, readFileSync } from "node:fs";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import {
parseConnectionOfferFromUrl,
type ConnectionOffer,
} from "@getpaseo/protocol/connection-offer";
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import { WebSocket } from "ws";
connectToDaemon as connectNodeClient,
normalizeDaemonHost,
resolveDaemonPassword,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from "@getpaseo/client/node";
import { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
export {
normalizeDaemonHost,
resolveDaemonPassword,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
};
export interface ConnectOptions {
host?: string;
timeout?: number;
@@ -28,26 +27,14 @@ export interface DaemonConnectionCommandError {
details: string;
}
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT = 15000;
const PID_FILENAME = "paseo.pid";
type DaemonTarget =
| {
type: "tcp";
url: string;
}
| {
type: "ipc";
url: string;
socketPath: string;
};
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
return (
options?.host ?? process.env.PASEO_HOST ?? resolveDefaultDaemonHosts()[0] ?? "localhost:6767"
);
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? "localhost:6767";
}
export function buildDaemonConnectionCommandError(options: {
@@ -63,324 +50,16 @@ export function buildDaemonConnectionCommandError(options: {
};
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("tcp://")) {
try {
const parsed = parseConnectionUri(trimmed);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
const query = new URLSearchParams();
if (parsed.useTls) {
query.set("ssl", "true");
}
if (parsed.password) {
query.set("password", parsed.password);
}
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return `tcp://${endpoint}${suffix}`;
} catch {
return null;
}
}
if (
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
trimmed.startsWith("\\\\.\\pipe\\")
) {
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
return `unix://${trimmed}`;
}
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`;
}
return trimmed.includes(":") ? trimmed : null;
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host);
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME);
if (!existsSync(pidPath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
listen?: unknown;
sockPath?: unknown;
};
if (typeof parsed.listen === "string") return parsed.listen;
if (typeof parsed.sockPath === "string") return parsed.sockPath;
return null;
} catch {
return null;
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost;
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
if (isIpcDaemonHost(pidHost)) {
return pidHost;
}
const config = loadConfig(paseoHome, { env });
const configuredHost = normalizeDaemonHost(config.listen);
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
if (!isTcpDaemonHost(configuredHost)) {
return null;
}
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env);
const candidates: string[] = [];
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
if (configuredIpcHost) {
candidates.push(configuredIpcHost);
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
if (configuredTcpHost) {
candidates.push(configuredTcpHost);
}
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST;
if (explicitHost) {
return [explicitHost];
}
return resolveDefaultDaemonHosts();
}
function stripIpcPrefix(trimmed: string): string {
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
return trimmed;
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim();
if (
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
trimmed.startsWith("\\\\.\\pipe\\")
) {
const socketPath = stripIpcPrefix(trimmed);
if (!socketPath) {
throw new Error("Invalid IPC daemon target: missing socket path");
}
const isUnixSocket = trimmed.startsWith("unix://");
return {
type: "ipc",
url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
socketPath,
};
}
if (trimmed.startsWith("tcp://")) {
const parsed = parseConnectionUri(trimmed);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
return {
type: "tcp",
url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }),
};
}
return {
type: "tcp",
url: `ws://${trimmed}/ws`,
};
}
export function resolveDaemonPassword(host: string): string | undefined {
const trimmed = host.trim();
if (trimmed.startsWith("tcp://")) {
const fromUri = parseConnectionUri(trimmed).password;
if (fromUri) return fromUri;
}
const fromEnv = process.env.PASEO_PASSWORD;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (
url: string,
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
): WebSocketLike => {
return new WebSocket(url, options?.protocols, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as WebSocketLike;
};
}
/**
* Create and connect a daemon client
* Returns the connected client or throws if connection fails
*/
async function tryConnectHost(
host: string,
password: string | undefined,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
): Promise<{ client: DaemonClient } | { error: unknown }> {
const target = resolveDaemonTarget(host);
const client = new DaemonClient({
url: target.url,
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
password,
connectTimeoutMs: timeout,
webSocketFactory: (
url: string,
config?: { headers?: Record<string, string>; protocols?: string[] },
) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
protocols: config?.protocols,
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
});
try {
await client.connect();
return { client };
} catch (error) {
await client.close().catch(() => {});
return { error };
}
}
async function connectViaRelayOffer(
offer: ConnectionOffer,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
): Promise<DaemonClient> {
const url = buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
});
const client = new DaemonClient({
url,
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
connectTimeoutMs: timeout,
webSocketFactory: (
target: string,
config?: { headers?: Record<string, string>; protocols?: string[] },
) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }),
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
reconnect: { enabled: false },
});
try {
await client.connect();
return client;
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
const lastError = client.lastError ? ` (${client.lastError})` : "";
throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error });
}
}
function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null {
if (!host) return null;
try {
return parseConnectionOfferFromUrl(host);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error });
}
}
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const clientId = await getOrCreateCliClientId();
const nodeWebSocketFactory = createNodeWebSocketFactory();
const explicitHost = options?.host ?? process.env.PASEO_HOST;
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
}
const hosts = resolveDaemonHostCandidates(options);
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
if (index >= hosts.length) {
if (lastError instanceof Error) throw lastError;
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
const host = hosts[index];
const password = resolveDaemonPassword(host);
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
if ("client" in result) {
return result.client;
}
return tryNext(index + 1, result.error);
}
return tryNext(0, null);
return connectNodeClient({
appVersion: resolveCliVersion(),
clientId: await getOrCreateCliClientId(),
clientType: "cli",
host: options?.host,
timeoutMs: options?.timeout,
});
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options);
@@ -389,57 +68,21 @@ export async function tryConnectToDaemon(options?: ConnectOptions): Promise<Daem
}
}
/** Minimal agent type for ID resolution */
interface AgentLike {
id: string;
title?: string | null;
}
/**
* Resolve an agent ID from a partial ID or name.
* Supports:
* - Full ID match
* - Prefix match (first N characters)
* - Title/name match (case-insensitive)
*
* Returns the full agent ID if found, null otherwise.
*/
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
if (!idOrName || agents.length === 0) {
return null;
}
if (!idOrName || agents.length === 0) return null;
const query = idOrName.toLowerCase();
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName);
if (exactMatch) {
return exactMatch.id;
}
// Try ID prefix match
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id;
}
// Try title/name match (case-insensitive)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query);
if (titleMatches.length === 1 && titleMatches[0]) {
return titleMatches[0].id;
}
// Try partial title match
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query));
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
return partialTitleMatches[0].id;
}
// If we have multiple prefix matches and no unique title match, return first prefix match
const firstPrefixMatch = prefixMatches[0];
if (firstPrefixMatch) {
return firstPrefixMatch.id;
}
return null;
const exact = agents.find((agent) => agent.id === idOrName);
if (exact) return exact.id;
const prefixes = agents.filter((agent) => agent.id.toLowerCase().startsWith(query));
if (prefixes.length === 1) return prefixes[0]?.id ?? null;
const exactTitle = agents.filter((agent) => agent.title?.toLowerCase() === query);
if (exactTitle.length === 1) return exactTitle[0]?.id ?? null;
const partialTitle = agents.filter((agent) => agent.title?.toLowerCase().includes(query));
if (partialTitle.length === 1) return partialTitle[0]?.id ?? null;
return prefixes[0]?.id ?? null;
}

View File

@@ -13,6 +13,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"default": "./dist/node.js"
},
"./internal/daemon-client": {
"types": "./dist/daemon-client.d.ts",
"default": "./dist/daemon-client.js"
@@ -37,10 +41,12 @@
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.1",
"@getpaseo/relay": "0.2.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/ws": "^8.5.14",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
}

View File

@@ -0,0 +1,131 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import {
normalizeDaemonHost,
resolveDaemonPassword,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from "./node.js";
const cleanup: string[] = [];
afterEach(() => {
for (const directory of cleanup.splice(0)) rmSync(directory, { recursive: true, force: true });
});
test("discovers the configured local socket before TCP fallback", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-"));
cleanup.push(paseoHome);
mkdirSync(paseoHome, { recursive: true });
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo.sock" }),
);
writeFileSync(
path.join(paseoHome, "config.json"),
JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }),
);
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
"unix:///tmp/paseo.sock",
"127.0.0.1:7777",
"localhost:6767",
]);
});
test("discovers the running daemon TCP address from its PID record", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-tcp-"));
cleanup.push(paseoHome);
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "127.0.0.1:7789" }),
);
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
"127.0.0.1:7789",
"localhost:6767",
]);
});
test("normalizes the daemon's bare IPv6 loopback PID address", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-ipv6-"));
cleanup.push(paseoHome);
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "::1:7789" }),
);
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
"[::1]:7789",
"localhost:6767",
]);
expect(resolveDaemonTarget("::1:7789")).toEqual({
type: "tcp",
url: "ws://[::1]:7789/ws",
});
});
test("normalizes TCP, Unix, pipe, and Windows path-shaped targets", () => {
expect(normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=secret")).toBe(
"tcp://Example.com:6767?ssl=true&password=secret",
);
expect(resolveDaemonTarget("unix:///tmp/paseo.sock")).toEqual({
type: "ipc",
url: "ws+unix:///tmp/paseo.sock:/ws",
socketPath: "/tmp/paseo.sock",
});
expect(normalizeDaemonHost("C:\\Users\\fixture\\paseo.sock")).toBeNull();
});
test("keeps explicit and environment passwords process-local", () => {
expect(resolveDaemonPassword("tcp://localhost:6767?password=query-secret", {})).toBe(
"query-secret",
);
expect(resolveDaemonPassword("localhost:6767", { PASEO_PASSWORD: "env-secret" })).toBe(
"env-secret",
);
});
test("preserves the legacy PORT fallback when no listen setting exists", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-port-"));
cleanup.push(paseoHome);
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PORT: "7788" })).toEqual([
"127.0.0.1:7788",
"localhost:6767",
]);
});
test("skips malformed discovered candidates and keeps the default fallback", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-malformed-"));
cleanup.push(paseoHome);
writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "tcp://bad" }));
writeFileSync(
path.join(paseoHome, "config.json"),
JSON.stringify({ daemon: { listen: "C:\\invalid\\socket" } }),
);
expect(
resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PASEO_LISTEN: "tcp://missing-port" }),
).toEqual(["localhost:6767"]);
});
test("ignores a stale PID listener before the configured daemon", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-stale-pid-"));
cleanup.push(paseoHome);
writeFileSync(
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: 2_147_483_647, listen: "127.0.0.1:7789" }),
);
writeFileSync(
path.join(paseoHome, "config.json"),
JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }),
);
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
"127.0.0.1:7777",
"localhost:6767",
]);
});

445
packages/client/src/node.ts Normal file
View File

@@ -0,0 +1,445 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { slugify } from "@getpaseo/protocol/branch-slug";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import {
parseConnectionOfferFromUrl,
type ConnectionOffer,
} from "@getpaseo/protocol/connection-offer";
import type { PaseoConfigRaw, PaseoConfigRevision } from "@getpaseo/protocol/messages";
import { WebSocket } from "ws";
import { DaemonClient, type WebSocketLike } from "./daemon-client.js";
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT_MS = 15_000;
const execFileAsync = promisify(execFile);
export interface NodeHostConnectionOptions {
appVersion: string;
clientId?: string;
clientType?: "cli" | "mcp";
env?: NodeJS.ProcessEnv;
host?: string;
timeoutMs?: number;
}
export interface HostAutomation {
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 }>;
close(): Promise<void>;
}
interface PersistedHostConfig {
daemon?: { listen?: unknown };
}
type DaemonTarget = { type: "tcp"; url: string } | { type: "ipc"; url: string; socketPath: string };
export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
const configured = env.PASEO_HOME ?? "~/.paseo";
const expanded =
configured === "~" ? os.homedir() : configured.replace(/^~\//, `${os.homedir()}/`);
return path.resolve(expanded);
}
export function normalizeDaemonHost(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
if (value.startsWith("tcp://")) {
try {
const parsed = parseConnectionUri(value);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
const query = new URLSearchParams();
if (parsed.useTls) query.set("ssl", "true");
if (parsed.password) query.set("password", parsed.password);
const suffix = query.size > 0 ? `?${query.toString()}` : "";
return `tcp://${endpoint}${suffix}`;
} catch {
return null;
}
}
if (value.startsWith("unix://") || value.startsWith("pipe://")) return value;
if (value.startsWith("\\\\.\\pipe\\")) return `pipe://${value}`;
if (value.startsWith("/") || value.startsWith("~")) return `unix://${value}`;
if (/^[A-Za-z]:[/\\]/.test(value)) return null;
if (/^\d+$/.test(value)) return `127.0.0.1:${value}`;
const ipv6Loopback = normalizeBareIpv6Loopback(value);
if (ipv6Loopback) return ipv6Loopback;
if (value.startsWith("::1:")) return null;
return value.includes(":") ? value : null;
}
function readConfiguredListen(paseoHome: string): string | null {
const configPath = path.join(paseoHome, "config.json");
if (!existsSync(configPath)) return null;
try {
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as PersistedHostConfig;
return typeof parsed.daemon?.listen === "string" ? parsed.daemon.listen : null;
} catch {
return null;
}
}
function readPidListen(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, "paseo.pid");
if (!existsSync(pidPath)) return null;
try {
const parsed = JSON.parse(readFileSync(pidPath, "utf8")) as {
pid?: unknown;
listen?: unknown;
sockPath?: unknown;
};
if (
typeof parsed.pid !== "number" ||
!Number.isInteger(parsed.pid) ||
!isProcessRunning(parsed.pid)
) {
return null;
}
if (typeof parsed.listen === "string") return parsed.listen;
return typeof parsed.sockPath === "string" ? parsed.sockPath : null;
} catch {
return null;
}
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM";
}
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env);
const direct = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
const pid = normalizeDaemonHost(readPidListen(paseoHome) ?? "");
const configuredListen = readConfiguredListen(paseoHome);
const configured = normalizeDaemonHost(configuredListen ?? "");
const port =
!env.PASEO_LISTEN && !configuredListen && /^\d+$/.test(env.PORT ?? "")
? normalizeDaemonHost(env.PORT ?? "")
: null;
const rawCandidates = [direct, pid, configured].filter(
(candidate): candidate is string => candidate !== null,
);
const ipc = rawCandidates.filter(
(candidate) => candidate.startsWith("unix://") || candidate.startsWith("pipe://"),
);
const tcp = [direct, pid, configured, port].filter(
(candidate): candidate is string =>
candidate !== null &&
!candidate.startsWith("unix://") &&
!candidate.startsWith("pipe://") &&
candidate !== "127.0.0.1:6767",
);
const candidates = [...ipc, ...tcp];
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const value = host.trim();
const isIpc =
value.startsWith("unix://") || value.startsWith("pipe://") || value.startsWith("\\\\.\\pipe\\");
if (isIpc) {
const socketPath = value.replace(/^(?:unix|pipe):\/\//, "").trim();
if (!socketPath) throw new Error("Invalid IPC daemon target: missing socket path");
return {
type: "ipc",
url: value.startsWith("unix://") ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
socketPath,
};
}
if (value.startsWith("tcp://")) {
const parsed = parseConnectionUri(value);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }) };
}
const endpoint = normalizeBareIpv6Loopback(value) ?? normalizeHostPort(value);
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: false }) };
}
function normalizeBareIpv6Loopback(value: string): string | null {
const match = value.match(/^::1:(\d{1,5})$/);
if (!match) return null;
try {
return normalizeHostPort(`[::1]:${match[1]}`);
} catch {
return null;
}
}
export function resolveDaemonPassword(
host: string,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
if (host.startsWith("tcp://")) {
const password = parseConnectionUri(host).password;
if (password) return password;
}
return env.PASEO_PASSWORD?.trim() || undefined;
}
function createWebSocket(
target: DaemonTarget,
): (
url: string,
options?: { headers?: Record<string, string>; protocols?: string[] },
) => WebSocketLike {
return (url, options) =>
new WebSocket(url, options?.protocols, {
headers: options?.headers,
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
}) as unknown as WebSocketLike;
}
async function connectCandidate(
host: string,
options: Required<
Pick<NodeHostConnectionOptions, "appVersion" | "clientId" | "clientType" | "timeoutMs">
> & {
env: NodeJS.ProcessEnv;
},
): Promise<DaemonClient> {
const target = resolveDaemonTarget(host);
const client = new DaemonClient({
url: target.url,
clientId: options.clientId,
clientType: options.clientType,
appVersion: options.appVersion,
password: resolveDaemonPassword(host, options.env),
connectTimeoutMs: options.timeoutMs,
webSocketFactory: createWebSocket(target),
reconnect: { enabled: false },
});
try {
await client.connect();
return client;
} catch (error) {
await client.close().catch(() => undefined);
throw error;
}
}
async function connectRelay(
offer: ConnectionOffer,
options: Required<
Pick<NodeHostConnectionOptions, "appVersion" | "clientId" | "clientType" | "timeoutMs">
>,
): Promise<DaemonClient> {
const url = buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
});
const client = new DaemonClient({
url,
clientId: options.clientId,
clientType: options.clientType,
appVersion: options.appVersion,
connectTimeoutMs: options.timeoutMs,
webSocketFactory: createWebSocket({ type: "tcp", url }),
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
reconnect: { enabled: false },
});
try {
await client.connect();
return client;
} catch (error) {
await client.close().catch(() => undefined);
throw error;
}
}
export async function connectToDaemon(options: NodeHostConnectionOptions): Promise<DaemonClient> {
const env = options.env ?? process.env;
let hosts: string[];
if (options.host) hosts = [options.host];
else if (env.PASEO_HOST) hosts = [env.PASEO_HOST];
else hosts = resolveDefaultDaemonHosts(env);
const identity = {
appVersion: options.appVersion,
clientId: options.clientId ?? `node-${randomUUID()}`,
clientType: options.clientType ?? ("cli" as const),
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
};
if (hosts.length === 1) {
const offer = parseOffer(hosts[0]);
if (offer) return connectRelay(offer, identity);
}
let lastError: unknown = new Error("No Paseo daemon targets were discovered.");
for (const host of hosts) {
try {
return await connectCandidate(host, {
...identity,
env,
});
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function parseOffer(host: string): ConnectionOffer | null {
try {
return parseConnectionOfferFromUrl(host);
} catch (error) {
const isPaseoUrl = host.startsWith("paseo://") || host.includes("/connect#");
if (isPaseoUrl) throw error;
return null;
}
}
class DaemonHostAutomation implements HostAutomation {
constructor(private readonly client: DaemonClient) {}
async addProject(rootPath: string): Promise<void> {
const result = await this.client.addProject(rootPath);
if (!result.project) throw new Error(result.error ?? `Unable to add ${rootPath}`);
}
async openCheckout(checkoutPath: string): Promise<void> {
const result = await this.client.openProject(checkoutPath);
if (!result.workspace) throw new Error(result.error ?? `Unable to open ${checkoutPath}`);
}
async readProjectConfig(rootPath: string) {
const result = await this.client.readProjectConfig(rootPath);
if (!result.ok) throw new Error(`Unable to read project config: ${result.error.code}`);
return { config: result.config, revision: result.revision };
}
async writeProjectConfig(input: {
rootPath: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
}): Promise<void> {
const result = await this.client.writeProjectConfig({
repoRoot: input.rootPath,
config: input.config,
expectedRevision: input.expectedRevision,
});
if (!result.ok) throw new Error(`Unable to write project config: ${result.error.code}`);
}
async ensureCheckout(input: {
rootPath: string;
refName: string;
directoryName: string;
}): Promise<{ path: string; created: boolean }> {
const refName = input.refName.replace(/^refs\/heads\//, "");
if (!refName) throw new Error("Checkout branch is required");
const directorySlug = slugify(input.directoryName);
if (!directorySlug)
throw new Error(`Unable to derive a checkout name from ${input.directoryName}`);
const listed = await this.client.getPaseoWorktreeList({ repoRoot: input.rootPath });
if (listed.error)
throw new Error(`Unable to inspect existing checkouts: ${listed.error.message}`);
const existing = listed.worktrees.find(
(worktree) => path.basename(worktree.worktreePath) === directorySlug,
);
if (existing && existing.branchName !== refName) {
throw new Error(
`Checkout name ${directorySlug} is already used by branch ${existing.branchName ?? "unknown"}.`,
);
}
if (existing && existsSync(existing.worktreePath)) {
const opened = await this.client.openProject(existing.worktreePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Unable to open ${existing.worktreePath}`);
}
return { path: existing.worktreePath, created: false };
}
await removeMissingCheckoutRegistration(input.rootPath, refName);
const result = await this.client.createPaseoWorktree({
cwd: input.rootPath,
worktreeSlug: directorySlug,
action: "checkout",
refName,
});
const checkoutPath = result.workspace?.workspaceDirectory;
if (!checkoutPath) throw new Error(result.error ?? `Unable to check out ${refName}`);
return { path: checkoutPath, created: true };
}
close(): Promise<void> {
return this.client.close();
}
}
async function removeMissingCheckoutRegistration(rootPath: string, refName: string): Promise<void> {
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], {
cwd: rootPath,
encoding: "utf8",
});
const expectedBranch = `refs/heads/${refName.replace(/^refs\/heads\//, "")}`;
const stale = parseGitWorktreeList(stdout).find(
(worktree) => worktree.branch === expectedBranch && !existsSync(worktree.path),
);
if (!stale) return;
// ensureCheckout is the explicit repair boundary: remove only the missing
// registration that blocks the requested branch, never unrelated worktrees.
await execFileAsync("git", ["worktree", "remove", "--force", stale.path], {
cwd: rootPath,
});
}
function parseGitWorktreeList(stdout: string): Array<{ path: string; branch: string | null }> {
return stdout
.trim()
.split(/\n\s*\n/)
.flatMap((entry) => {
const worktreePath = entry.match(/^worktree (.+)$/m)?.[1];
if (!worktreePath) return [];
return [{ path: worktreePath, branch: entry.match(/^branch (.+)$/m)?.[1] ?? null }];
});
}
export async function connectHostAutomation(
options: NodeHostConnectionOptions,
): Promise<HostAutomation> {
const client = await connectToDaemon(options);
if (client.getLastServerInfoMessage()?.features?.hostAutomation !== true) {
await client.close();
throw new Error(
"This Paseo host does not support project import. Update the host to use this.",
);
}
return new DaemonHostAutomation(client);
}

View File

@@ -18,6 +18,7 @@
"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: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",
@@ -27,6 +28,7 @@
},
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/import": "0.1.0-beta.2",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -34,6 +36,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.2.0",

View File

@@ -232,7 +232,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveDesktopAppVersion(): string {
export function resolveDesktopAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}

View File

@@ -104,6 +104,7 @@ describe("desktop packaging", () => {
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

@@ -0,0 +1,66 @@
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 { DesktopImportRunner, type ImportTarget } from "./runner.js";
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<ImportTarget> => {
const status = await resolveDesktopDaemonStatus();
return {
status: status.status,
desktopManaged: status.desktopManaged,
listen: status.listen,
home: status.home,
appVersion: resolveDesktopAppVersion(),
daemonVersion: status.version,
passwordProtected: Boolean(process.env.PASEO_PASSWORD?.trim()) || hasPassword(status.home),
};
},
});
export function registerImportIpc(): void {
ipcMain.handle("paseo:imports:run", async (event, input: unknown) => {
const source = readSource(input);
return {
runId: await imports.run(source, (output) => {
if (!event.sender.isDestroyed()) event.sender.send("paseo:imports:output", output);
}),
};
});
}
function readSource(input: unknown): string {
if (typeof input !== "object" || input === null || !("source" in input)) {
throw new Error("Import source is required.");
}
const source = (input as { source?: unknown }).source;
if (typeof source !== "string") throw new Error("Import source is required.");
return source;
}
function hasPassword(paseoHome: string): boolean {
const configPath = path.join(paseoHome, "config.json");
if (!existsSync(configPath)) return false;
try {
const config = JSON.parse(readFileSync(configPath, "utf8")) as {
daemon?: { auth?: { password?: unknown } };
};
return typeof config.daemon?.auth?.password === "string";
} catch {
return true;
}
}

View File

@@ -0,0 +1,186 @@
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 },
"Import is unavailable while the local host is password-protected.",
],
[
{ ...eligibleTarget, status: "stopped" as const },
"Import requires the running Paseo Desktop-managed host.",
],
[
{ ...eligibleTarget, daemonVersion: "0.1.109" },
"Update the Desktop-managed host before importing.",
],
] as const)("refuses an ineligible import target", async (target, message) => {
const runner = createRunner({ target });
await expect(runner.run("source-fixture", () => undefined)).rejects.toThrow(message);
});
test("accepts a Desktop-managed host without inferring ownership from its listen address", async () => {
const runner = createRunner({
target: { ...eligibleTarget, listen: "0.0.0.0:6767" },
});
await expect(runner.run("source-fixture", () => undefined)).resolves.toBe("run-1");
});
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,110 @@
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 };
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 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 Error(`Unsupported import source: ${sourceId}`);
}
return source;
}
}
export function assertEligibleTarget(target: ImportTarget): void {
if (target.status !== "running" || !target.desktopManaged) {
throw new Error("Import requires the running Paseo Desktop-managed host.");
}
if (target.passwordProtected) {
throw new Error("Import is unavailable while the local host is password-protected.");
}
if (normalizeVersion(target.daemonVersion) !== normalizeVersion(target.appVersion)) {
throw new Error("Update the Desktop-managed host before importing.");
}
}
function normalizeVersion(version: string | null): string | null {
return version?.trim().replace(/^v/i, "") || null;
}

View File

@@ -89,6 +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 { 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";
@@ -881,6 +882,7 @@ async function bootstrap(): Promise<void> {
registerOpenerHandlers();
registerEditorTargetHandlers();
registerBrowserAutomationIpc();
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,6 +17,14 @@ interface AttachedBrowserRegistration {
webContentsId: 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,
invoke: (command: string, args?: Record<string, unknown>) =>
@@ -34,6 +42,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
});
},
},
imports: {
run: (input: { source: string }) =>
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:imports:output", listener);
return () => ipcRenderer.removeListener("paseo:imports:output", listener);
},
},
window: {
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
ipcRenderer.invoke("paseo:window:openNew", options),

View File

@@ -2721,6 +2721,8 @@ export const ServerInfoStatusPayloadSchema = z
workspaceGithubRepositorySearch: z.boolean().optional(),
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: z.boolean().optional(),
// COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111.
hostAutomation: z.boolean().optional(),
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.

View File

@@ -50,7 +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\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"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",

View File

@@ -1609,6 +1609,7 @@ export async function createPaseoDaemon(
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await providerSnapshotManager.shutdown();
workspaceGitService.dispose();
terminalManager.killAll();
speechService.stop();
await scheduleService.stop().catch(() => undefined);

View File

@@ -0,0 +1,224 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { connectHostAutomation, connectToDaemon } from "@getpaseo/client/node";
import { afterEach, expect, test } from "vitest";
import { hashDaemonPassword } from "../auth.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const cleanupPaths = new Set<string>();
const cleanupDaemons = new Set<TestPaseoDaemon>();
const cleanupConnections = new Set<{ close(): Promise<void> }>();
afterEach(async () => {
await Promise.all([...cleanupConnections].map((connection) => connection.close()));
cleanupConnections.clear();
await Promise.all([...cleanupDaemons].map((daemon) => daemon.close()));
cleanupDaemons.clear();
for (const target of cleanupPaths) {
try {
rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
// Windows can retain a temporary repository handle past daemon shutdown.
// The runner discards its temp root; every other cleanup failure remains fatal.
if (!isWindowsBusyError(error)) throw error;
}
}
cleanupPaths.clear();
});
function isWindowsBusyError(error: unknown): boolean {
return (
process.platform === "win32" &&
error instanceof Error &&
"code" in error &&
error.code === "EBUSY"
);
}
test("a different live checkout continues to protect its branch", async () => {
const repoRoot = createRepository();
const existingPath = path.join(path.dirname(repoRoot), "existing-feature");
execFileSync("git", ["worktree", "add", existingPath, "feature"], { cwd: repoRoot });
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseo = await connectHostAutomation({
appVersion: "0.1.110",
clientId: "import-live-protection-e2e",
env: {},
host: `127.0.0.1:${daemon.port}`,
});
cleanupConnections.add(paseo);
await expect(
paseo.ensureCheckout({
rootPath: repoRoot,
refName: "feature",
directoryName: "different-feature",
}),
).rejects.toThrow(/already checked out|in use/i);
expect(realpathSync(existingPath)).toBe(existingPath);
});
test("ensureCheckout repairs only the requested missing registration", async () => {
const repoRoot = createRepository();
execFileSync("git", ["branch", "unrelated"], { cwd: repoRoot });
const staleFeature = path.join(path.dirname(repoRoot), "stale-feature");
const staleUnrelated = path.join(path.dirname(repoRoot), "stale-unrelated");
execFileSync("git", ["worktree", "add", staleFeature, "feature"], { cwd: repoRoot });
execFileSync("git", ["worktree", "add", staleUnrelated, "unrelated"], { cwd: repoRoot });
rmSync(staleFeature, { recursive: true, force: true });
rmSync(staleUnrelated, { recursive: true, force: true });
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseo = await connectHostAutomation({
appVersion: "0.1.110",
clientId: "import-stale-registration-e2e",
env: {},
host: `127.0.0.1:${daemon.port}`,
});
cleanupConnections.add(paseo);
const ensured = await paseo.ensureCheckout({
rootPath: repoRoot,
refName: "feature",
directoryName: "imported-feature",
});
expect(path.basename(ensured.path)).toBe("imported-feature");
const registrations = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
});
expect(registrations).toContain("branch refs/heads/feature");
expect(registrations).toContain("branch refs/heads/unrelated");
});
test("normalized checkout-name collisions cannot reuse another branch", async () => {
const repoRoot = createRepository();
execFileSync("git", ["branch", "other-feature"], { cwd: repoRoot });
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseo = await connectHostAutomation({
appVersion: "0.1.110",
clientId: "import-slug-collision-e2e",
env: {},
host: `127.0.0.1:${daemon.port}`,
});
cleanupConnections.add(paseo);
await paseo.addProject(repoRoot);
const first = await paseo.ensureCheckout({
rootPath: repoRoot,
refName: "feature",
directoryName: "Foo.Bar",
});
const same = await paseo.ensureCheckout({
rootPath: repoRoot,
refName: "refs/heads/feature",
directoryName: "foo-bar",
});
const firstStats = statSync(first.path);
expect(statSync(same.path)).toMatchObject({ dev: firstStats.dev, ino: firstStats.ino });
await expect(
paseo.ensureCheckout({
rootPath: repoRoot,
refName: "other-feature",
directoryName: "foo-bar",
}),
).rejects.toThrow(/already used by branch feature/);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: first.path,
encoding: "utf8",
}).trim(),
).toBe("feature");
});
test("the public connector authenticates to a real password-protected daemon and closes cleanly", async () => {
const daemon = await createTestPaseoDaemon({
auth: { password: hashDaemonPassword("connector-secret") },
});
cleanupDaemons.add(daemon);
await expect(
connectToDaemon({
appVersion: "0.1.110",
clientId: "import-auth-failure-e2e",
env: { PASEO_PASSWORD: "wrong-secret" },
host: `127.0.0.1:${daemon.port}`,
timeoutMs: 2_000,
}),
).rejects.toThrow(/auth|password|unauthorized/i);
const client = await connectToDaemon({
appVersion: "0.1.110",
clientId: "import-auth-success-e2e",
env: { PASEO_PASSWORD: "connector-secret" },
host: `127.0.0.1:${daemon.port}`,
});
const beforeClose = await client.fetchAgents();
await client.close();
expect(beforeClose.entries).toEqual([]);
await expect(client.fetchAgents()).rejects.toThrow();
});
test("the public connector uses PORT fallback and skips malformed discovered candidates", async () => {
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const paseoHome = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-connector-home-")));
cleanupPaths.add(paseoHome);
writeFileSync(path.join(paseoHome, "paseo.pid"), '{"listen":"tcp://missing-port"}');
const client = await connectToDaemon({
appVersion: "0.1.110",
clientId: "import-port-fallback-e2e",
env: { PASEO_HOME: paseoHome, PORT: String(daemon.port) },
});
cleanupConnections.add(client);
expect((await client.fetchAgents()).entries).toEqual([]);
});
test.skipIf(process.platform === "win32")(
"the public connector reaches a real daemon through its Unix socket",
async () => {
const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-connector-ipc-")));
cleanupPaths.add(parent);
const socketPath = path.join(parent, "paseo.sock");
const daemon = await createTestPaseoDaemon({ listen: socketPath, allowIpc: true });
cleanupDaemons.add(daemon);
expect(daemon.listenTarget).toEqual({ type: "socket", path: socketPath });
expect(existsSync(socketPath)).toBe(true);
const client = await connectToDaemon({
appVersion: "0.1.110",
clientId: "import-ipc-e2e",
env: {},
host: `unix://${socketPath}`,
});
cleanupConnections.add(client);
expect((await client.fetchAgents()).entries).toEqual([]);
},
);
function createRepository(): string {
const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-import-daemon-")));
cleanupPaths.add(parent);
const repoRoot = path.join(parent, "repo");
execFileSync("git", ["init", "-b", "main", repoRoot]);
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repoRoot });
writeFileSync(path.join(repoRoot, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
cwd: repoRoot,
});
execFileSync("git", ["branch", "feature"], { cwd: repoRoot });
return realpathSync(repoRoot);
}

View File

@@ -3,7 +3,7 @@ import { WebSocket } from "ws";
import pino from "pino";
import { Writable } from "node:stream";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
@@ -21,6 +21,7 @@ import {
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
import { connectToDaemon } from "@getpaseo/client/node";
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
@@ -172,7 +173,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const startRelay = async () => {
relayStdoutLines = [];
relayPort = await getAvailablePort();
const relayDir = path.resolve(process.cwd(), "../relay");
const relayDir = fileURLToPath(new URL("../../../../relay", import.meta.url));
relayProcess = spawn(
"npx",
[
@@ -362,6 +363,41 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
}
}, 90000);
test("public Node connector follows a real encrypted relay offer", async () => {
await startRelay();
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
relayUseTls: false,
relayPublicUseTls: false,
});
let client: Awaited<ReturnType<typeof connectToDaemon>> | null = null;
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
client = await connectToDaemon({
appVersion: "0.1.110",
clientId: "public-node-relay-e2e",
env: {},
host: offerUrl,
timeoutMs: 20_000,
});
expect((await client.fetchAgents()).entries).toEqual([]);
} finally {
await client?.close();
await daemon.close();
await stopRelay();
}
}, 90_000);
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";

View File

@@ -838,6 +838,7 @@ export class Session {
emit: (msg) => this.emit(msg),
},
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
});
this.daemonSession = new DaemonSession({

View File

@@ -4,7 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
@@ -33,12 +33,37 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers
};
}
function makeSubsystem(records: PersistedProjectRecord[]) {
function workspaceRecord(
projectId: string,
cwd: string,
archivedAt: string | null = null,
): PersistedWorkspaceRecord {
return {
workspaceId: `workspace:${cwd}`,
projectId,
cwd,
kind: "worktree",
displayName: "Workspace",
branch: "feature",
worktreeRoot: cwd,
isPaseoOwnedWorktree: true,
mainRepoRoot: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt,
};
}
function makeSubsystem(
records: PersistedProjectRecord[],
workspaces: PersistedWorkspaceRecord[] = [],
) {
const emitted: SessionOutboundMessage[] = [];
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new ProjectConfigSession({
host,
projectRegistry: { list: async () => records },
workspaceRegistry: { list: async () => workspaces },
logger: pino({ level: "silent" }),
});
return { subsystem, emitted };
@@ -179,6 +204,92 @@ describe("ProjectConfigSession", () => {
]);
});
test("read and write accept an active workspace belonging to an active project", async () => {
const repoRoot = makeRoot();
const workspaceRoot = makeRoot();
const project = projectRecord(repoRoot);
const { subsystem, emitted } = makeSubsystem(
[project],
[workspaceRecord(project.projectId, workspaceRoot)],
);
await subsystem.handleWriteProjectConfigRequest({
type: "write_project_config_request",
requestId: "workspace-write-1",
repoRoot: workspaceRoot,
config: { worktree: { setup: "npm ci" } },
expectedRevision: null,
});
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId: "workspace-read-1",
repoRoot: workspaceRoot,
});
expect(emitted).toEqual([
expect.objectContaining({
type: "write_project_config_response",
payload: expect.objectContaining({ ok: true, repoRoot: workspaceRoot }),
}),
expect.objectContaining({
type: "read_project_config_response",
payload: expect.objectContaining({
ok: true,
repoRoot: workspaceRoot,
config: { worktree: { setup: "npm ci" } },
}),
}),
]);
});
test("read rejects archived workspaces and workspaces owned by archived projects", async () => {
const activeRepoRoot = makeRoot();
const archivedRepoRoot = makeRoot();
const archivedWorkspaceRoot = makeRoot();
const orphanedWorkspaceRoot = makeRoot();
const activeProject = projectRecord(activeRepoRoot);
const archivedProject = projectRecord(archivedRepoRoot, "2026-01-02T00:00:00.000Z");
const { subsystem, emitted } = makeSubsystem(
[activeProject, archivedProject],
[
workspaceRecord(activeProject.projectId, archivedWorkspaceRoot, "2026-01-02T00:00:00.000Z"),
workspaceRecord(archivedProject.projectId, orphanedWorkspaceRoot),
],
);
for (const [requestId, repoRoot] of [
["archived-workspace", archivedWorkspaceRoot],
["archived-owner", orphanedWorkspaceRoot],
] as const) {
await subsystem.handleReadProjectConfigRequest({
type: "read_project_config_request",
requestId,
repoRoot,
});
}
expect(emitted).toEqual([
{
type: "read_project_config_response",
payload: {
requestId: "archived-workspace",
repoRoot: archivedWorkspaceRoot,
ok: false,
error: { code: "project_not_found" },
},
},
{
type: "read_project_config_response",
payload: {
requestId: "archived-owner",
repoRoot: orphanedWorkspaceRoot,
ok: false,
error: { code: "project_not_found" },
},
},
]);
});
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
const staleRoot = makeRoot();
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));

View File

@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
import { resolve, sep } from "path";
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ProjectRegistry } from "../../workspace-registry.js";
import type { ProjectRegistry, WorkspaceRegistry } from "../../workspace-registry.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
@@ -16,24 +16,27 @@ export interface ProjectConfigSessionHost {
export interface ProjectConfigSessionOptions {
host: ProjectConfigSessionHost;
projectRegistry: Pick<ProjectRegistry, "list">;
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
logger: pino.Logger;
}
/**
* A client's read/write surface for a project's on-disk paseo.json. Resolves the
* request's repoRoot against the known (non-archived) project roots — accepting a
* trailing slash or a symlink via realpath — then reads or writes the config
* substrate and emits the matching response. Reaches no state beyond the injected
* project registry and the outbound channel.
* request's repoRoot against known active project and workspace roots — accepting
* a trailing slash or a symlink via realpath — then reads or writes the config
* substrate and emits the matching response. Workspace roots remain authorized
* only while both the workspace and its owning project are active.
*/
export class ProjectConfigSession {
private readonly host: ProjectConfigSessionHost;
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
private readonly workspaceRegistry: Pick<WorkspaceRegistry, "list">;
private readonly logger: pino.Logger;
constructor(options: ProjectConfigSessionOptions) {
this.host = options.host;
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.logger = options.logger;
}
@@ -153,15 +156,27 @@ export class ProjectConfigSession {
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(repoRoot);
const projects = await this.projectRegistry.list();
const activeProjectIds = new Set<string>();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
activeProjectIds.add(project.projectId);
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
const workspaces = await this.workspaceRegistry.list();
for (const workspace of workspaces) {
if (workspace.archivedAt !== null || !activeProjectIds.has(workspace.projectId)) {
continue;
}
const workspaceRoot = canonicalizeConfigRoot(workspace.cwd);
if (requestedRoot === workspaceRoot) {
return workspaceRoot;
}
}
return null;
}
}

View File

@@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
import pino from "pino";
import {
createPaseoDaemon,
type ListenTarget,
type PaseoDaemonConfig,
type PaseoOpenAIConfig,
type PaseoSpeechConfig,
@@ -19,6 +20,7 @@ interface TestPaseoDaemonOptions {
downloadTokenTtlMs?: number;
corsAllowedOrigins?: string[];
listen?: string;
allowIpc?: boolean;
logger?: Parameters<typeof createPaseoDaemon>[1];
mcpEnabled?: boolean;
mcpDebug?: boolean;
@@ -49,6 +51,7 @@ export interface TestPaseoDaemon {
config: PaseoDaemonConfig;
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
port: number;
listenTarget: ListenTarget;
paseoHome: string;
staticDir: string;
close: () => Promise<void>;
@@ -96,8 +99,8 @@ export async function createTestPaseoDaemon(
try {
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
const listenTarget = daemon.getListenTarget();
if (!listenTarget || listenTarget.type !== "tcp") {
throw new Error("Test daemon did not expose a bound TCP listen target");
if (!listenTarget || (listenTarget.type !== "tcp" && !options.allowIpc)) {
throw new Error("Test daemon did not expose an allowed listen target");
}
const close = async (): Promise<void> => {
@@ -115,7 +118,8 @@ export async function createTestPaseoDaemon(
return {
config,
daemon,
port: listenTarget.port,
port: listenTarget.type === "tcp" ? listenTarget.port : 0,
listenTarget,
paseoHome,
staticDir,
close,
@@ -157,7 +161,7 @@ async function prepareTestDaemonConfig(
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
const listenHost = options.listen ?? "127.0.0.1";
const config: PaseoDaemonConfig = {
listen: `${listenHost}:0`,
listen: options.allowIpc ? listenHost : `${listenHost}:0`,
paseoHome,
daemonVersion: options.daemonVersion,
desktopManaged: options.desktopManaged,

View File

@@ -1376,6 +1376,8 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: true,
// COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111.
hostAutomation: true,
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.

View File

@@ -117,6 +117,7 @@ export async function createWorktreeCore(
worktreesRoot: input.worktreesRoot,
});
if (existingWorktree) {
assertExistingWorktreeMatchesIntent(existingWorktree, intent, normalizedSlug);
return { worktree: existingWorktree, intent, repoRoot, created: false };
}
@@ -135,6 +136,22 @@ export async function createWorktreeCore(
};
}
function assertExistingWorktreeMatchesIntent(
existingWorktree: WorktreeConfig,
intent: WorktreeCreationIntent,
slug: string,
): void {
const intendedBranchName =
intent.kind === "checkout-change-request" || intent.kind === "checkout-github-pr"
? (intent.localBranchName ?? intent.headRef)
: intent.branchName;
if (existingWorktree.branchName !== intendedBranchName) {
throw new Error(
`Worktree name ${slug} is already used by branch ${existingWorktree.branchName}.`,
);
}
}
async function resolveForge(
repoRoot: string,
deps: CreateWorktreeCoreDeps,