mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e8a44848 | ||
|
|
81bffbd01a | ||
|
|
9519c5ba99 | ||
|
|
194822b939 | ||
|
|
2505075beb | ||
|
|
0c3c61be94 | ||
|
|
7046b118e9 | ||
|
|
3fed2f2cd9 | ||
|
|
234f0d9b75 | ||
|
|
548e15c177 | ||
|
|
6240b2d17c | ||
|
|
4e99e4c18c | ||
|
|
2f07c5918f | ||
|
|
3e6061b169 | ||
|
|
4ed944db2f | ||
|
|
af244d0f02 | ||
|
|
f2124f8cf9 | ||
|
|
152cdcabad | ||
|
|
44da3cdf05 | ||
|
|
a8c6c2f4bc | ||
|
|
faccbaae59 | ||
|
|
e61c06706c | ||
|
|
c54ddfd07f | ||
|
|
ddaaaa27be | ||
|
|
fbd02437dc | ||
|
|
b447a650a7 | ||
|
|
4d2acfbeb5 | ||
|
|
d6bc201f46 | ||
|
|
5e75e463ec | ||
|
|
35d4ae201a | ||
|
|
b93d326fb2 | ||
|
|
9505e43925 | ||
|
|
2f0e3451de | ||
|
|
28cac5859a | ||
|
|
59d9a25a0d | ||
|
|
60462337b6 | ||
|
|
f44ef807ef | ||
|
|
f030ddefb1 | ||
|
|
f7a6548b68 | ||
|
|
ab9c9b96a7 | ||
|
|
8fe11e3bb0 |
@@ -200,6 +200,41 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
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:
|
app-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
+13
-1
@@ -91,7 +91,19 @@ it does not depend on the server.
|
|||||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||||
facade. App and CLI may import the low-level driver from
|
facade. App and CLI may import the low-level driver from
|
||||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
`@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)
|
### `packages/app` — Mobile + web client (Expo)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
Generated
+30
-1
@@ -31726,7 +31726,6 @@
|
|||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
||||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -31891,6 +31890,12 @@
|
|||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/srvx": {
|
||||||
"version": "0.11.15",
|
"version": "0.11.15",
|
||||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||||
@@ -36436,10 +36441,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||||
"@getpaseo/relay": "0.2.0-beta.1",
|
"@getpaseo/relay": "0.2.0-beta.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
@@ -36450,6 +36457,7 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
|
"@getpaseo/import": "0.1.0-beta.2",
|
||||||
"@getpaseo/server": "*",
|
"@getpaseo/server": "*",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
@@ -36457,6 +36465,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "41.2.0",
|
"electron": "41.2.0",
|
||||||
@@ -36467,6 +36476,26 @@
|
|||||||
"wait-on": "8.0.5"
|
"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": {
|
"packages/desktop/node_modules/@types/node": {
|
||||||
"version": "24.6.0",
|
"version": "24.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -709,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
|||||||
PASEO_HOME: args.paseoHome,
|
PASEO_HOME: args.paseoHome,
|
||||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
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_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||||
PASEO_NODE_ENV: "development",
|
PASEO_NODE_ENV: "development",
|
||||||
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"], {
|
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"test:browser": "vitest run --project browser",
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
"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: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:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "Desktop Chrome",
|
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"] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
|
|||||||
desktopMaxWidth?: number;
|
desktopMaxWidth?: number;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
presentation?: "push" | "replace";
|
presentation?: "push" | "replace";
|
||||||
|
/** Whether gestures may dismiss the compact bottom sheet. */
|
||||||
|
dismissible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
|
|||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
presentation,
|
presentation,
|
||||||
|
dismissible = true,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={handleDismiss}
|
onDismiss={handleDismiss}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose={dismissible}
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
handleIndicatorStyle={handleIndicatorStyle}
|
handleIndicatorStyle={handleIndicatorStyle}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
type SkillsStatus,
|
type SkillsStatus,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
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 CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||||
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
|
|||||||
onUninstall={handleUninstallSkills}
|
onUninstall={handleUninstallSkills}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<ExternalImport source={conductorImportSource} />
|
||||||
</View>
|
</View>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ interface DaemonStatusData {
|
|||||||
logs: DesktopDaemonLogs;
|
logs: DesktopDaemonLogs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDaemonStatus() {
|
interface UseDaemonStatusOptions {
|
||||||
|
refetchOnMount?: boolean | "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const enabled = shouldUseDesktopDaemon();
|
const enabled = shouldUseDesktopDaemon();
|
||||||
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
|
|||||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: options.refetchOnMount ?? "always",
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { getIsElectron } from "@/constants/platform";
|
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 { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||||
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
|
|||||||
|
|
||||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||||
const isElectron = getIsElectron();
|
const isElectron = getIsElectron();
|
||||||
const localDaemon = useLocalDaemonServerIdState();
|
|
||||||
const hosts = useHosts();
|
|
||||||
const { settings, updateSettings } = useDesktopSettings();
|
const { settings, updateSettings } = useDesktopSettings();
|
||||||
const { data, setStatus, refetch } = useDaemonStatus();
|
const { data, setStatus, refetch } = useDaemonStatus();
|
||||||
const { enable } = useBuiltInDaemonManagement({
|
const { enable } = useBuiltInDaemonManagement({
|
||||||
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
|||||||
refreshStatus: refetch,
|
refreshStatus: refetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLocalhostConfigured =
|
const visible = isElectron && data?.status.desktopManaged === false;
|
||||||
localDaemon.status === "resolved" &&
|
|
||||||
localDaemon.serverId !== null &&
|
|
||||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
|
||||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
const onPress = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ export interface DesktopInvokeBridge {
|
|||||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
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 {
|
export interface DesktopHostBridge {
|
||||||
platform?: string;
|
platform?: string;
|
||||||
invoke?: DesktopInvokeBridge["invoke"];
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
@@ -178,6 +191,7 @@ export interface DesktopHostBridge {
|
|||||||
webUtils?: DesktopWebUtilsBridge;
|
webUtils?: DesktopWebUtilsBridge;
|
||||||
menu?: DesktopMenuBridge;
|
menu?: DesktopMenuBridge;
|
||||||
browser?: DesktopBrowserBridge;
|
browser?: DesktopBrowserBridge;
|
||||||
|
imports?: DesktopImportsBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -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 |
@@ -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"),
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
}));
|
||||||
@@ -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 },
|
||||||
|
}));
|
||||||
@@ -1117,6 +1117,20 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
|
import: {
|
||||||
|
availability: {
|
||||||
|
checking: "جارٍ التحقق من توفّر الاستيراد…",
|
||||||
|
"host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
|
||||||
|
},
|
||||||
|
actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" },
|
||||||
|
unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
|
||||||
|
complete: "اكتمل الاستيراد.",
|
||||||
|
failed: "فشل الاستيراد.",
|
||||||
|
description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.",
|
||||||
|
sheetTitle: "الاستيراد من {{source}}",
|
||||||
|
confirmation:
|
||||||
|
"سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات {{source}}.",
|
||||||
|
},
|
||||||
cli: {
|
cli: {
|
||||||
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
||||||
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
||||||
|
|||||||
@@ -1128,6 +1128,20 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
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: {
|
cli: {
|
||||||
statusFailed: "Unable to check CLI install status.",
|
statusFailed: "Unable to check CLI install status.",
|
||||||
installFailed: "Unable to install the Paseo CLI.",
|
installFailed: "Unable to install the Paseo CLI.",
|
||||||
|
|||||||
@@ -1157,6 +1157,20 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
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: {
|
cli: {
|
||||||
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
||||||
installFailed: "No se puede instalar elPaseoCLI.",
|
installFailed: "No se puede instalar elPaseoCLI.",
|
||||||
|
|||||||
@@ -1159,6 +1159,20 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
|
import: {
|
||||||
|
availability: {
|
||||||
|
checking: "Vérification de la disponibilité de l’importation…",
|
||||||
|
"host-not-running": "Démarrez l’hôte géré par Desktop avant l’importation.",
|
||||||
|
},
|
||||||
|
actions: { import: "Importer", importing: "Importation…", done: "Terminé" },
|
||||||
|
unavailable: "L’importation depuis l’application de bureau est indisponible.",
|
||||||
|
complete: "Importation terminée.",
|
||||||
|
failed: "Échec de l’importation.",
|
||||||
|
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: {
|
cli: {
|
||||||
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
||||||
installFailed: "Impossible d'installer lePaseoCLI.",
|
installFailed: "Impossible d'installer lePaseoCLI.",
|
||||||
|
|||||||
@@ -1132,6 +1132,20 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
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: {
|
cli: {
|
||||||
statusFailed: "CLIのインストール状態を確認できません。",
|
statusFailed: "CLIのインストール状態を確認できません。",
|
||||||
installFailed: "Paseo CLIをインストールできません。",
|
installFailed: "Paseo CLIをインストールできません。",
|
||||||
|
|||||||
@@ -1144,6 +1144,20 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
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: {
|
cli: {
|
||||||
statusFailed: "Não foi possível verificar o status de instalação da 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.",
|
installFailed: "Não foi possível instalar a CLI do Paseo.",
|
||||||
|
|||||||
@@ -1147,6 +1147,20 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
|
import: {
|
||||||
|
availability: {
|
||||||
|
checking: "Проверка доступности импорта…",
|
||||||
|
"host-not-running": "Запустите управляемый Desktop хост перед импортом.",
|
||||||
|
},
|
||||||
|
actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" },
|
||||||
|
unavailable: "Импорт в настольном приложении недоступен.",
|
||||||
|
complete: "Импорт завершён.",
|
||||||
|
failed: "Не удалось выполнить импорт.",
|
||||||
|
description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.",
|
||||||
|
sheetTitle: "Импорт из {{source}}",
|
||||||
|
confirmation:
|
||||||
|
"Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные {{source}} не изменятся.",
|
||||||
|
},
|
||||||
cli: {
|
cli: {
|
||||||
statusFailed: "Невозможно проверить статус установки CLI.",
|
statusFailed: "Невозможно проверить статус установки CLI.",
|
||||||
installFailed: "Невозможно установить PaseoCLI.",
|
installFailed: "Невозможно установить PaseoCLI.",
|
||||||
|
|||||||
@@ -1103,6 +1103,20 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
|
import: {
|
||||||
|
availability: {
|
||||||
|
checking: "正在检查导入是否可用…",
|
||||||
|
"host-not-running": "请先启动由桌面端管理的主机再导入。",
|
||||||
|
},
|
||||||
|
actions: { import: "导入", importing: "正在导入…", done: "完成" },
|
||||||
|
unavailable: "桌面端导入不可用。",
|
||||||
|
complete: "导入完成。",
|
||||||
|
failed: "导入失败。",
|
||||||
|
description: "从这台 Mac 导入项目、设置和工作树。",
|
||||||
|
sheetTitle: "从 {{source}} 导入",
|
||||||
|
confirmation:
|
||||||
|
"Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。{{source}} 数据不会被修改。",
|
||||||
|
},
|
||||||
cli: {
|
cli: {
|
||||||
statusFailed: "无法检查 CLI 安装状态。",
|
statusFailed: "无法检查 CLI 安装状态。",
|
||||||
installFailed: "无法安装 Paseo CLI。",
|
installFailed: "无法安装 Paseo CLI。",
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
|
||||||
import {
|
import {
|
||||||
buildDaemonWebSocketUrl,
|
connectToDaemon as connectNodeClient,
|
||||||
buildRelayWebSocketUrl,
|
normalizeDaemonHost,
|
||||||
normalizeHostPort,
|
resolveDaemonPassword,
|
||||||
parseConnectionUri,
|
resolveDaemonTarget,
|
||||||
shouldUseTlsForDefaultHostedRelay,
|
resolveDefaultDaemonHosts,
|
||||||
} from "@getpaseo/protocol/daemon-endpoints";
|
} from "@getpaseo/client/node";
|
||||||
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";
|
|
||||||
import { getOrCreateCliClientId } from "./client-id.js";
|
import { getOrCreateCliClientId } from "./client-id.js";
|
||||||
import { resolveCliVersion } from "../version.js";
|
import { resolveCliVersion } from "../version.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
normalizeDaemonHost,
|
||||||
|
resolveDaemonPassword,
|
||||||
|
resolveDaemonTarget,
|
||||||
|
resolveDefaultDaemonHosts,
|
||||||
|
};
|
||||||
|
|
||||||
export interface ConnectOptions {
|
export interface ConnectOptions {
|
||||||
host?: string;
|
host?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@@ -28,26 +27,14 @@ export interface DaemonConnectionCommandError {
|
|||||||
details: string;
|
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 {
|
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: {
|
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> {
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
return connectNodeClient({
|
||||||
const clientId = await getOrCreateCliClientId();
|
appVersion: resolveCliVersion(),
|
||||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
clientId: await getOrCreateCliClientId(),
|
||||||
|
clientType: "cli",
|
||||||
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
host: options?.host,
|
||||||
const offer = parseHostOfferOrNull(explicitHost);
|
timeoutMs: options?.timeout,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to connect to the daemon, returns null if connection fails
|
|
||||||
*/
|
|
||||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
||||||
try {
|
try {
|
||||||
return await connectToDaemon(options);
|
return await connectToDaemon(options);
|
||||||
@@ -389,57 +68,21 @@ export async function tryConnectToDaemon(options?: ConnectOptions): Promise<Daem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal agent type for ID resolution */
|
|
||||||
interface AgentLike {
|
interface AgentLike {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string | null;
|
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 {
|
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
|
||||||
if (!idOrName || agents.length === 0) {
|
if (!idOrName || agents.length === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = idOrName.toLowerCase();
|
const query = idOrName.toLowerCase();
|
||||||
|
const exact = agents.find((agent) => agent.id === idOrName);
|
||||||
// Try exact ID match first
|
if (exact) return exact.id;
|
||||||
const exactMatch = agents.find((a) => a.id === idOrName);
|
const prefixes = agents.filter((agent) => agent.id.toLowerCase().startsWith(query));
|
||||||
if (exactMatch) {
|
if (prefixes.length === 1) return prefixes[0]?.id ?? null;
|
||||||
return exactMatch.id;
|
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));
|
||||||
// Try ID prefix match
|
if (partialTitle.length === 1) return partialTitle[0]?.id ?? null;
|
||||||
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
|
return prefixes[0]?.id ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
},
|
},
|
||||||
|
"./node": {
|
||||||
|
"types": "./dist/node.d.ts",
|
||||||
|
"default": "./dist/node.js"
|
||||||
|
},
|
||||||
"./internal/daemon-client": {
|
"./internal/daemon-client": {
|
||||||
"types": "./dist/daemon-client.d.ts",
|
"types": "./dist/daemon-client.d.ts",
|
||||||
"default": "./dist/daemon-client.js"
|
"default": "./dist/daemon-client.js"
|
||||||
@@ -37,10 +41,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||||
"@getpaseo/relay": "0.2.0-beta.1",
|
"@getpaseo/relay": "0.2.0-beta.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
|
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
|
||||||
"build:main": "tsc -p tsconfig.json",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"capture-harness": "./capture-harness/run.sh",
|
"capture-harness": "./capture-harness/run.sh",
|
||||||
|
"test:import-electron": "npm --prefix ../.. run test:e2e:import-electron --workspace=@getpaseo/app",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:win": "powershell ./scripts/dev.ps1",
|
"dev:win": "powershell ./scripts/dev.ps1",
|
||||||
"test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs",
|
"test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
|
"@getpaseo/import": "0.1.0-beta.2",
|
||||||
"@getpaseo/server": "*",
|
"@getpaseo/server": "*",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "41.2.0",
|
"electron": "41.2.0",
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDesktopAppVersion(): string {
|
export function resolveDesktopAppVersion(): string {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ describe("desktop packaging", () => {
|
|||||||
for (const required of ["@getpaseo/cli", "@getpaseo/server"]) {
|
for (const required of ["@getpaseo/cli", "@getpaseo/server"]) {
|
||||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
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", () => {
|
it("launches the packaged macOS CLI through Helper instead of the main app executable", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ import { runDesktopStartup } from "./desktop-startup.js";
|
|||||||
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
||||||
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
||||||
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
||||||
|
import { registerImportIpc } from "./integrations/imports/ipc.js";
|
||||||
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
||||||
|
|
||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
@@ -881,6 +882,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
registerOpenerHandlers();
|
registerOpenerHandlers();
|
||||||
registerEditorTargetHandlers();
|
registerEditorTargetHandlers();
|
||||||
registerBrowserAutomationIpc();
|
registerBrowserAutomationIpc();
|
||||||
|
registerImportIpc();
|
||||||
|
|
||||||
// In-app "Open in new window": opens a window that lands on the given project
|
// 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).
|
// via the same open-project flow as a CLI launch (no move, no ownership).
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ interface AttachedBrowserRegistration {
|
|||||||
webContentsId: number;
|
webContentsId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DesktopImportOutput =
|
||||||
|
| {
|
||||||
|
runId: string;
|
||||||
|
type: "event";
|
||||||
|
event: { level: "info" | "warning" | "error"; message: string };
|
||||||
|
}
|
||||||
|
| { runId: string; type: "status"; succeeded: boolean };
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("paseoDesktop", {
|
contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
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: {
|
window: {
|
||||||
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
||||||
ipcRenderer.invoke("paseo:window:openNew", options),
|
ipcRenderer.invoke("paseo:window:openNew", options),
|
||||||
|
|||||||
@@ -2721,6 +2721,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||||
projectCreateDirectory: z.boolean().optional(),
|
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.
|
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
commitsList: z.boolean().optional(),
|
commitsList: z.boolean().optional(),
|
||||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
"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:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||||
"test:integration:local": "vitest run local.e2e.test.ts",
|
"test:integration:local": "vitest run local.e2e.test.ts",
|
||||||
|
|||||||
@@ -1609,6 +1609,7 @@ export async function createPaseoDaemon(
|
|||||||
detachAgentStoragePersistence();
|
detachAgentStoragePersistence();
|
||||||
await agentStorage.flush().catch(() => undefined);
|
await agentStorage.flush().catch(() => undefined);
|
||||||
await providerSnapshotManager.shutdown();
|
await providerSnapshotManager.shutdown();
|
||||||
|
workspaceGitService.dispose();
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
speechService.stop();
|
speechService.stop();
|
||||||
await scheduleService.stop().catch(() => undefined);
|
await scheduleService.stop().catch(() => undefined);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { WebSocket } from "ws";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { Writable } from "node:stream";
|
import { Writable } from "node:stream";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||||
import { WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
|
import { WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
|
||||||
|
import { connectToDaemon } from "@getpaseo/client/node";
|
||||||
|
|
||||||
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
|
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
|
||||||
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
|
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 () => {
|
const startRelay = async () => {
|
||||||
relayStdoutLines = [];
|
relayStdoutLines = [];
|
||||||
relayPort = await getAvailablePort();
|
relayPort = await getAvailablePort();
|
||||||
const relayDir = path.resolve(process.cwd(), "../relay");
|
const relayDir = fileURLToPath(new URL("../../../../relay", import.meta.url));
|
||||||
relayProcess = spawn(
|
relayProcess = spawn(
|
||||||
"npx",
|
"npx",
|
||||||
[
|
[
|
||||||
@@ -362,6 +363,41 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
}
|
}
|
||||||
}, 90000);
|
}, 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 () => {
|
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
|
||||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||||
|
|
||||||
|
|||||||
@@ -838,6 +838,7 @@ export class Session {
|
|||||||
emit: (msg) => this.emit(msg),
|
emit: (msg) => this.emit(msg),
|
||||||
},
|
},
|
||||||
projectRegistry: this.projectRegistry,
|
projectRegistry: this.projectRegistry,
|
||||||
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
this.daemonSession = new DaemonSession({
|
this.daemonSession = new DaemonSession({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
|
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";
|
import type { SessionOutboundMessage } from "../../messages.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
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 emitted: SessionOutboundMessage[] = [];
|
||||||
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
|
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
|
||||||
const subsystem = new ProjectConfigSession({
|
const subsystem = new ProjectConfigSession({
|
||||||
host,
|
host,
|
||||||
projectRegistry: { list: async () => records },
|
projectRegistry: { list: async () => records },
|
||||||
|
workspaceRegistry: { list: async () => workspaces },
|
||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
});
|
});
|
||||||
return { subsystem, emitted };
|
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 () => {
|
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
|
||||||
const staleRoot = makeRoot();
|
const staleRoot = makeRoot();
|
||||||
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
|
|||||||
import { resolve, sep } from "path";
|
import { resolve, sep } from "path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||||
import type { ProjectRegistry } from "../../workspace-registry.js";
|
import type { ProjectRegistry, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||||
import {
|
import {
|
||||||
readPaseoConfigForEdit,
|
readPaseoConfigForEdit,
|
||||||
writePaseoConfigForEdit,
|
writePaseoConfigForEdit,
|
||||||
@@ -16,24 +16,27 @@ export interface ProjectConfigSessionHost {
|
|||||||
export interface ProjectConfigSessionOptions {
|
export interface ProjectConfigSessionOptions {
|
||||||
host: ProjectConfigSessionHost;
|
host: ProjectConfigSessionHost;
|
||||||
projectRegistry: Pick<ProjectRegistry, "list">;
|
projectRegistry: Pick<ProjectRegistry, "list">;
|
||||||
|
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A client's read/write surface for a project's on-disk paseo.json. Resolves the
|
* 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
|
* request's repoRoot against known active project and workspace roots — accepting
|
||||||
* trailing slash or a symlink via realpath — then reads or writes the config
|
* 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
|
* substrate and emits the matching response. Workspace roots remain authorized
|
||||||
* project registry and the outbound channel.
|
* only while both the workspace and its owning project are active.
|
||||||
*/
|
*/
|
||||||
export class ProjectConfigSession {
|
export class ProjectConfigSession {
|
||||||
private readonly host: ProjectConfigSessionHost;
|
private readonly host: ProjectConfigSessionHost;
|
||||||
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
|
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
|
||||||
|
private readonly workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
|
|
||||||
constructor(options: ProjectConfigSessionOptions) {
|
constructor(options: ProjectConfigSessionOptions) {
|
||||||
this.host = options.host;
|
this.host = options.host;
|
||||||
this.projectRegistry = options.projectRegistry;
|
this.projectRegistry = options.projectRegistry;
|
||||||
|
this.workspaceRegistry = options.workspaceRegistry;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,15 +156,27 @@ export class ProjectConfigSession {
|
|||||||
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
|
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
|
||||||
const requestedRoot = canonicalizeConfigRoot(repoRoot);
|
const requestedRoot = canonicalizeConfigRoot(repoRoot);
|
||||||
const projects = await this.projectRegistry.list();
|
const projects = await this.projectRegistry.list();
|
||||||
|
const activeProjectIds = new Set<string>();
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
if (project.archivedAt !== null) {
|
if (project.archivedAt !== null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
activeProjectIds.add(project.projectId);
|
||||||
const projectRoot = canonicalizeConfigRoot(project.rootPath);
|
const projectRoot = canonicalizeConfigRoot(project.rootPath);
|
||||||
if (requestedRoot === projectRoot) {
|
if (requestedRoot === projectRoot) {
|
||||||
return 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import {
|
import {
|
||||||
createPaseoDaemon,
|
createPaseoDaemon,
|
||||||
|
type ListenTarget,
|
||||||
type PaseoDaemonConfig,
|
type PaseoDaemonConfig,
|
||||||
type PaseoOpenAIConfig,
|
type PaseoOpenAIConfig,
|
||||||
type PaseoSpeechConfig,
|
type PaseoSpeechConfig,
|
||||||
@@ -19,6 +20,7 @@ interface TestPaseoDaemonOptions {
|
|||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
corsAllowedOrigins?: string[];
|
corsAllowedOrigins?: string[];
|
||||||
listen?: string;
|
listen?: string;
|
||||||
|
allowIpc?: boolean;
|
||||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
mcpDebug?: boolean;
|
mcpDebug?: boolean;
|
||||||
@@ -49,6 +51,7 @@ export interface TestPaseoDaemon {
|
|||||||
config: PaseoDaemonConfig;
|
config: PaseoDaemonConfig;
|
||||||
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
|
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
|
||||||
port: number;
|
port: number;
|
||||||
|
listenTarget: ListenTarget;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
staticDir: string;
|
staticDir: string;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
@@ -96,8 +99,8 @@ export async function createTestPaseoDaemon(
|
|||||||
try {
|
try {
|
||||||
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
||||||
const listenTarget = daemon.getListenTarget();
|
const listenTarget = daemon.getListenTarget();
|
||||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
if (!listenTarget || (listenTarget.type !== "tcp" && !options.allowIpc)) {
|
||||||
throw new Error("Test daemon did not expose a bound TCP listen target");
|
throw new Error("Test daemon did not expose an allowed listen target");
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = async (): Promise<void> => {
|
const close = async (): Promise<void> => {
|
||||||
@@ -115,7 +118,8 @@ export async function createTestPaseoDaemon(
|
|||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
daemon,
|
daemon,
|
||||||
port: listenTarget.port,
|
port: listenTarget.type === "tcp" ? listenTarget.port : 0,
|
||||||
|
listenTarget,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
staticDir,
|
staticDir,
|
||||||
close,
|
close,
|
||||||
@@ -157,7 +161,7 @@ async function prepareTestDaemonConfig(
|
|||||||
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
|
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
|
||||||
const listenHost = options.listen ?? "127.0.0.1";
|
const listenHost = options.listen ?? "127.0.0.1";
|
||||||
const config: PaseoDaemonConfig = {
|
const config: PaseoDaemonConfig = {
|
||||||
listen: `${listenHost}:0`,
|
listen: options.allowIpc ? listenHost : `${listenHost}:0`,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
daemonVersion: options.daemonVersion,
|
daemonVersion: options.daemonVersion,
|
||||||
desktopManaged: options.desktopManaged,
|
desktopManaged: options.desktopManaged,
|
||||||
|
|||||||
@@ -1376,6 +1376,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
projectRemove: true,
|
projectRemove: true,
|
||||||
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||||
projectAdd: true,
|
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.
|
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
|
||||||
worktreeRestore: true,
|
worktreeRestore: true,
|
||||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export async function createWorktreeCore(
|
|||||||
worktreesRoot: input.worktreesRoot,
|
worktreesRoot: input.worktreesRoot,
|
||||||
});
|
});
|
||||||
if (existingWorktree) {
|
if (existingWorktree) {
|
||||||
|
assertExistingWorktreeMatchesIntent(existingWorktree, intent, normalizedSlug);
|
||||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
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(
|
async function resolveForge(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
deps: CreateWorktreeCoreDeps,
|
deps: CreateWorktreeCoreDeps,
|
||||||
|
|||||||
Reference in New Issue
Block a user