mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
41 Commits
76e336a1be
...
conductor-
| 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 |
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
@@ -200,6 +200,41 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
import-electron:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build app and server dependencies
|
||||
run: |
|
||||
npm run build:app-deps
|
||||
npm run build:server
|
||||
|
||||
- name: Build Desktop main
|
||||
run: npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run product import behavior
|
||||
run: npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Upload import behavior diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: import-electron-macos
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -91,7 +91,19 @@ it does not depend on the server.
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
code imports from `@getpaseo/client`. Node automation uses
|
||||
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
|
||||
selection, and the capability-gated host automation facade shared by the CLI and
|
||||
published import tools.
|
||||
|
||||
### External project import
|
||||
|
||||
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
|
||||
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
|
||||
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
|
||||
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
|
||||
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
|
||||
import-package release machinery.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
@@ -156,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
|
||||
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
|
||||
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
|
||||
ineligible-host UI:
|
||||
|
||||
```bash
|
||||
npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -31726,7 +31726,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -31891,6 +31890,12 @@
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/srvx": {
|
||||
"version": "0.11.15",
|
||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||
@@ -36436,10 +36441,12 @@
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
@@ -36450,6 +36457,7 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/import": "0.1.0-beta.2",
|
||||
"@getpaseo/server": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
@@ -36457,6 +36465,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
@@ -36467,6 +36476,26 @@
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@getpaseo/import": {
|
||||
"version": "0.1.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
|
||||
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"smol-toml": "^1.6.0",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"bin": {
|
||||
"paseo-import": "dist/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@getpaseo/client": ">=0.2.0-beta.2"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@types/node": {
|
||||
"version": "24.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
|
||||
|
||||
174
packages/app/e2e/conductor-import.electron.spec.ts
Normal file
174
packages/app/e2e/conductor-import.electron.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
_electron as electron,
|
||||
expect,
|
||||
test,
|
||||
type ElectronApplication,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
|
||||
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
|
||||
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
|
||||
let electronApp: ElectronApplication;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
installation = await createConductorInstallation();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await electronApp?.close();
|
||||
rmSync(installation.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
|
||||
electronApp = await launchProduct();
|
||||
const page = await electronApp.firstWindow();
|
||||
await openIntegrations(page);
|
||||
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
|
||||
|
||||
await openImport(page);
|
||||
await expect(
|
||||
page.getByText("Paseo will register valid repositories", { exact: false }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
|
||||
await expect(page.getByTestId("import-output")).toContainText(
|
||||
`Registered project ${installation.repo}.`,
|
||||
);
|
||||
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
writeFileSync(installation.databasePath, "not a sqlite database");
|
||||
await openImport(page);
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
|
||||
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
await stopImportHost();
|
||||
await page.getByRole("button", { name: "General", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
const row = page.getByTestId("conductor-import-row");
|
||||
await expect(row).toContainText("Start the Desktop-managed host before importing.");
|
||||
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
async function launchProduct(): Promise<ElectronApplication> {
|
||||
const metroPort = requiredEnvironment("E2E_METRO_PORT");
|
||||
return electron.launch({
|
||||
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
|
||||
env: {
|
||||
...process.env,
|
||||
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
|
||||
HOME: installation.home,
|
||||
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
|
||||
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
|
||||
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openIntegrations(page: Page): Promise<void> {
|
||||
const settings = page.getByRole("button", { name: "Settings", exact: true });
|
||||
await expect(settings).toBeVisible({ timeout: 90_000 });
|
||||
await settings.click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openImport(page: Page): Promise<void> {
|
||||
const button = page
|
||||
.getByTestId("conductor-import-row")
|
||||
.getByRole("button", { name: "Import", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("import-sheet")).toBeVisible();
|
||||
}
|
||||
|
||||
async function createConductorInstallation(): Promise<{
|
||||
root: string;
|
||||
home: string;
|
||||
repo: string;
|
||||
databasePath: string;
|
||||
}> {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
|
||||
const home = path.join(root, "home");
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(home, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", repo]);
|
||||
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "README.md"), "fixture\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
|
||||
cwd: repo,
|
||||
});
|
||||
|
||||
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
|
||||
mkdirSync(databaseDirectory, { recursive: true });
|
||||
const databasePath = path.join(databaseDirectory, "conductor.db");
|
||||
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`const fs = require("node:fs");
|
||||
const initSqlJs = require("sql.js");
|
||||
(async () => {
|
||||
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
|
||||
const databasePath = process.argv[1];
|
||||
const repo = process.argv[2];
|
||||
const database = new SQL.Database(fs.readFileSync(databasePath));
|
||||
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
|
||||
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
|
||||
database.run("DELETE FROM workspaces");
|
||||
fs.writeFileSync(databasePath, database.export());
|
||||
database.close();
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||
databasePath,
|
||||
repo,
|
||||
],
|
||||
{ cwd: repoRoot },
|
||||
);
|
||||
return { root, home, repo: realpathSync(repo), databasePath };
|
||||
}
|
||||
|
||||
async function stopImportHost(): Promise<void> {
|
||||
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
|
||||
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
|
||||
process.kill(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Import host ${pid} did not stop.`);
|
||||
}
|
||||
|
||||
function requiredEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required.`);
|
||||
return value;
|
||||
}
|
||||
@@ -709,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_LISTEN:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
|
||||
@@ -27,7 +27,10 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
testIgnore:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1"
|
||||
? ["**/*.real.spec.ts"]
|
||||
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/** Whether gestures may dismiss the compact bottom sheet. */
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
dismissible = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enablePanDownToClose={dismissible}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
import { conductorImportSource } from "@/desktop/imports/conductor";
|
||||
import { ExternalImport } from "@/desktop/imports/external-import";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
<ExternalImport source={conductorImportSource} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,11 @@ interface DaemonStatusData {
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
interface UseDaemonStatusOptions {
|
||||
refetchOnMount?: boolean | "always";
|
||||
}
|
||||
|
||||
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnMount: options.refetchOnMount ?? "always",
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
|
||||
|
||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
const isElectron = getIsElectron();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hosts = useHosts();
|
||||
const { settings, updateSettings } = useDesktopSettings();
|
||||
const { data, setStatus, refetch } = useDaemonStatus();
|
||||
const { enable } = useBuiltInDaemonManagement({
|
||||
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
refreshStatus: refetch,
|
||||
});
|
||||
|
||||
const isLocalhostConfigured =
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||
const visible = isElectron && data?.status.desktopManaged === false;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
void (async () => {
|
||||
|
||||
@@ -165,6 +165,19 @@ export interface DesktopInvokeBridge {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export type DesktopImportOutput =
|
||||
| {
|
||||
runId: string;
|
||||
type: "event";
|
||||
event: { level: "info" | "warning" | "error"; message: string };
|
||||
}
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
export interface DesktopImportsBridge {
|
||||
run?: (input: { source: string }) => Promise<{ runId: string }>;
|
||||
onOutput?: (handler: (output: DesktopImportOutput) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopHostBridge {
|
||||
platform?: string;
|
||||
invoke?: DesktopInvokeBridge["invoke"];
|
||||
@@ -178,6 +191,7 @@ export interface DesktopHostBridge {
|
||||
webUtils?: DesktopWebUtilsBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
browser?: DesktopBrowserBridge;
|
||||
imports?: DesktopImportsBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
116
packages/app/src/desktop/imports/conductor.svg
Normal file
116
packages/app/src/desktop/imports/conductor.svg
Normal file
@@ -0,0 +1,116 @@
|
||||
<svg width="115" height="174" viewBox="0 0 115 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="36" width="23" height="29">
|
||||
<path d="M0.499023 63.6992V37.251H22.373V63.6992H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_41_20251)">
|
||||
<path d="M4.57422 63.6992H22.373V37.251H4.57422C3.58785 37.2511 2.78711 38.0517 2.78711 39.0381V61.9121C2.78725 62.8984 3.58794 63.6991 4.57422 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask1_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="36" width="24" height="29">
|
||||
<path d="M18.7979 63.6992V37.251H40.6719V63.6992H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_41_20251)">
|
||||
<path d="M36.5977 63.6992H18.7988V37.251H36.5977C37.584 37.2511 38.3848 38.0517 38.3848 39.0381V61.9121C38.3846 62.8984 37.5839 63.6991 36.5977 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask2_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="73" width="23" height="28">
|
||||
<path d="M0.499023 100.297V73.8486H22.373V100.297H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_41_20251)">
|
||||
<path d="M4.57422 100.297H22.373V73.8486H4.57422C3.58785 73.8488 2.78711 74.6493 2.78711 75.6357V98.5098C2.78725 99.496 3.58794 100.297 4.57422 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask3_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="73" width="24" height="28">
|
||||
<path d="M18.7979 100.297V73.8486H40.6719V100.297H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_41_20251)">
|
||||
<path d="M36.5977 100.297H18.7988V73.8486H36.5977C37.584 73.8488 38.3848 74.6493 38.3848 75.6357V98.5098C38.3846 99.496 37.5839 100.297 36.5977 100.297Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask4_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="109" width="23" height="29">
|
||||
<path d="M0.499023 136.896V110.447H22.373V136.896H0.499023Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_41_20251)">
|
||||
<path d="M4.57422 136.896H22.373V110.447H4.57422C3.58785 110.447 2.78711 111.248 2.78711 112.234V135.108C2.78725 136.095 3.58794 136.895 4.57422 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask5_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="109" width="24" height="29">
|
||||
<path d="M18.7979 136.896V110.447H40.6719V136.896H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_41_20251)">
|
||||
<path d="M36.5977 136.896H18.7988V110.447H36.5977C37.584 110.447 38.3848 111.248 38.3848 112.234V135.108C38.3846 136.095 37.5839 136.895 36.5977 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask6_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="146" width="24" height="28">
|
||||
<path d="M18.7979 173.493V147.045H40.6719V173.493H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask6_41_20251)">
|
||||
<path d="M22.873 173.493H40.6719V147.045H22.873C21.8867 147.045 21.0859 147.846 21.0859 148.832V171.706C21.0861 172.692 21.8868 173.493 22.873 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask7_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="146" width="24" height="28">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask7_41_20251)">
|
||||
<path d="M37.0967 173.493V147.045H58.9707V173.493H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask8_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="146" width="24" height="28">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask8_41_20251)">
|
||||
<path d="M55.3955 173.493V147.045H77.2695V173.493H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask9_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="146" width="24" height="28">
|
||||
<path d="M73.6963 173.493V147.045H95.5703V173.493H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask9_41_20251)">
|
||||
<path d="M91.4941 173.493H73.6953V147.045H91.4941C92.4805 147.045 93.2812 147.846 93.2812 148.832V171.706C93.2811 172.692 92.4804 173.493 91.4941 173.493Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask10_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="109" width="24" height="29">
|
||||
<path d="M73.6963 136.896V110.447H95.5703V136.896H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask10_41_20251)">
|
||||
<path d="M77.7695 136.896H95.5684V110.447H77.7695C76.7832 110.447 75.9824 111.248 75.9824 112.234V135.108C75.9826 136.095 76.7833 136.895 77.7695 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask11_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="109" width="24" height="29">
|
||||
<path d="M91.9932 136.896V110.447H113.867V136.896H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask11_41_20251)">
|
||||
<path d="M109.793 136.896H91.9941V110.447H109.793C110.779 110.447 111.58 111.248 111.58 112.234V135.108C111.58 136.095 110.779 136.895 109.793 136.896Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask12_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="0" width="24" height="28">
|
||||
<path d="M18.7979 27.1006V0.652344H40.6719V27.1006H18.7979Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask12_41_20251)">
|
||||
<path d="M22.873 27.1006H40.6719V0.652344H22.873C21.8867 0.652488 21.0859 1.45305 21.0859 2.43945V25.3135C21.0861 26.2998 21.8868 27.1004 22.873 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask13_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="36" y="0" width="24" height="28">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask13_41_20251)">
|
||||
<path d="M37.0967 27.1006V0.652344H58.9707V27.1006H37.0967Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask14_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="54" y="0" width="24" height="28">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask14_41_20251)">
|
||||
<path d="M55.3955 27.1006V0.652344H77.2695V27.1006H55.3955Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask15_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="0" width="24" height="28">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask15_41_20251)">
|
||||
<path d="M73.6963 27.1006V0.652344H95.5703V27.1006H73.6963Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask16_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="0" width="24" height="28">
|
||||
<path d="M91.9932 27.1006V0.652344H113.867V27.1006H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask16_41_20251)">
|
||||
<path d="M109.793 27.1006H91.9941V0.652344H109.793C110.779 0.652488 111.58 1.45305 111.58 2.43945V25.3135C111.58 26.2998 110.779 27.1004 109.793 27.1006Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask17_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="73" y="36" width="24" height="29">
|
||||
<path d="M73.6963 63.6992V37.251H95.5703V63.6992H73.6963Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask17_41_20251)">
|
||||
<path d="M77.7695 63.6992H95.5684V37.251H77.7695C76.7832 37.2511 75.9824 38.0517 75.9824 39.0381V61.9121C75.9826 62.8984 76.7833 63.6991 77.7695 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
<mask id="mask18_41_20251" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="91" y="36" width="24" height="29">
|
||||
<path d="M91.9932 63.6992V37.251H113.867V63.6992H91.9932Z" fill="white" stroke="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask18_41_20251)">
|
||||
<path d="M109.793 63.6992H91.9941V37.251H109.793C110.779 37.2511 111.58 38.0517 111.58 39.0381V61.9121C111.58 62.8984 110.779 63.6991 109.793 63.6992Z" fill="#FFFFFF" stroke="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
8
packages/app/src/desktop/imports/conductor.ts
Normal file
8
packages/app/src/desktop/imports/conductor.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export const conductorImportSource: ImportSourceDescriptor = {
|
||||
id: "conductor",
|
||||
title: "Conductor",
|
||||
// Official light letter mark from https://www.conductor.build/brandkit.
|
||||
icon: require("./conductor.svg"),
|
||||
};
|
||||
71
packages/app/src/desktop/imports/external-import.tsx
Normal file
71
packages/app/src/desktop/imports/external-import.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isElectronRuntimeMac } from "@/desktop/host";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ImportSheet, type ImportSourceDescriptor } from "./import-sheet";
|
||||
|
||||
export function ExternalImport({ source }: { source: ImportSourceDescriptor }) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useDaemonStatus({ refetchOnMount: false });
|
||||
const desktopManaged = data?.status.desktopManaged;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const open = useCallback(() => setVisible(true), []);
|
||||
const close = useCallback(() => setVisible(false), []);
|
||||
if (!isElectronRuntimeMac()) return null;
|
||||
|
||||
const available = desktopManaged === true;
|
||||
let reason: string | null = null;
|
||||
if (desktopManaged === undefined) {
|
||||
reason = t("desktop.integrations.import.availability.checking");
|
||||
} else if (!available) {
|
||||
reason = t("desktop.integrations.import.availability.host-not-running");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={[settingsStyles.row, settingsStyles.rowBorder]}
|
||||
testID={`${source.id}-import-row`}
|
||||
>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.iconFrame}>
|
||||
<Image source={source.icon} style={styles.icon} resizeMode="contain" />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle}>{source.title}</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{reason ?? t("desktop.integrations.import.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={open}
|
||||
disabled={!available}
|
||||
testID={`${source.id}-import-open`}
|
||||
>
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
</View>
|
||||
<ImportSheet source={source} visible={visible} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
|
||||
iconFrame: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: "#282423",
|
||||
},
|
||||
icon: { width: 12, height: 18 },
|
||||
}));
|
||||
156
packages/app/src/desktop/imports/import-sheet.tsx
Normal file
156
packages/app/src/desktop/imports/import-sheet.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ImageSourcePropType } from "react-native";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopHost, type DesktopImportOutput } from "@/desktop/host";
|
||||
|
||||
type ImportState =
|
||||
| { status: "confirm" }
|
||||
| { status: "running"; runId: string | null; output: string }
|
||||
| { status: "complete"; succeeded: boolean; output: string }
|
||||
| { status: "failed"; message: string; output: string };
|
||||
|
||||
export interface ImportSourceDescriptor {
|
||||
id: string;
|
||||
icon: ImageSourcePropType;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function ImportSheet({
|
||||
source,
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
source: ImportSourceDescriptor;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<ImportState>({ status: "confirm" });
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("desktop.integrations.import.sheetTitle", { source: source.title }) }),
|
||||
[source.title, t],
|
||||
);
|
||||
const close = useCallback(() => {
|
||||
if (state.status !== "running") onClose();
|
||||
}, [onClose, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) setState({ status: "confirm" });
|
||||
}, [visible]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const bridge = getDesktopHost()?.imports;
|
||||
if (!bridge?.run || !bridge.onOutput) {
|
||||
setState({
|
||||
status: "failed",
|
||||
message: t("desktop.integrations.import.unavailable"),
|
||||
output: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({ status: "running", runId: null, output: "" });
|
||||
let runId: string | null = null;
|
||||
const unsubscribe = bridge.onOutput((event: DesktopImportOutput) => {
|
||||
if (runId && event.runId !== runId) return;
|
||||
setState((current) => reduceOutput(current, event));
|
||||
if (event.type === "status") unsubscribe();
|
||||
});
|
||||
try {
|
||||
const started = await bridge.run({ source: source.id });
|
||||
runId = started.runId;
|
||||
setState((current) =>
|
||||
current.status === "running" ? { ...current, runId: started.runId } : current,
|
||||
);
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
setState({
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
output: "",
|
||||
});
|
||||
}
|
||||
}, [source.id, t]);
|
||||
|
||||
const footer = useMemo(() => {
|
||||
if (state.status === "confirm") {
|
||||
return (
|
||||
<Button onPress={start} testID="import-confirm">
|
||||
{t("desktop.integrations.import.actions.import")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (state.status === "running") {
|
||||
return <Button disabled>{t("desktop.integrations.import.actions.importing")}</Button>;
|
||||
}
|
||||
return (
|
||||
<Button onPress={close} testID="import-done">
|
||||
{t("desktop.integrations.import.actions.done")}
|
||||
</Button>
|
||||
);
|
||||
}, [close, start, state.status, t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={close}
|
||||
footer={footer}
|
||||
desktopMaxWidth={640}
|
||||
testID="import-sheet"
|
||||
dismissible={state.status !== "running"}
|
||||
>
|
||||
{state.status === "confirm" ? (
|
||||
<Text style={styles.copy}>
|
||||
{t("desktop.integrations.import.confirmation", { source: source.title })}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{state.status === "complete" ? (
|
||||
<Text testID="import-result" style={styles.copy}>
|
||||
{state.succeeded
|
||||
? t("desktop.integrations.import.complete")
|
||||
: t("desktop.integrations.import.failed")}
|
||||
</Text>
|
||||
) : null}
|
||||
{state.status === "failed" ? (
|
||||
<Text testID="import-error" style={styles.error}>
|
||||
{state.message}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollView style={styles.output}>
|
||||
<Text selectable testID="import-output" style={styles.outputText}>
|
||||
{state.output}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function reduceOutput(state: ImportState, event: DesktopImportOutput): ImportState {
|
||||
if (state.status !== "running") return state;
|
||||
if (event.type === "status") {
|
||||
return { status: "complete", succeeded: event.succeeded, output: state.output };
|
||||
}
|
||||
const line = `${event.event.level.toUpperCase()}: ${event.event.message}\n`;
|
||||
return { ...state, output: `${state.output}${line}` };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: { gap: theme.spacing[3] },
|
||||
copy: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm },
|
||||
output: {
|
||||
minHeight: 180,
|
||||
maxHeight: 360,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
outputText: { color: theme.colors.foregroundMuted, fontFamily: "monospace", fontSize: 12 },
|
||||
}));
|
||||
@@ -1117,6 +1117,20 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "جارٍ التحقق من توفّر الاستيراد…",
|
||||
"host-not-running": "شغّل المضيف المُدار بواسطة تطبيق سطح المكتب قبل الاستيراد.",
|
||||
},
|
||||
actions: { import: "استيراد", importing: "جارٍ الاستيراد…", done: "تم" },
|
||||
unavailable: "الاستيراد عبر تطبيق سطح المكتب غير متاح.",
|
||||
complete: "اكتمل الاستيراد.",
|
||||
failed: "فشل الاستيراد.",
|
||||
description: "استيراد المشاريع والإعدادات وأشجار العمل من جهاز Mac هذا.",
|
||||
sheetTitle: "الاستيراد من {{source}}",
|
||||
confirmation:
|
||||
"سيسجّل Paseo المستودعات الصالحة ويدمج إعدادات المشروع المدعومة ويتبنّى أشجار العمل الجاهزة أو يعيد إنشاءها. لن تتغير بيانات {{source}}.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
|
||||
installFailed: "غير قادر على تثبيت PaseoCLI.",
|
||||
|
||||
@@ -1128,6 +1128,20 @@ export const en = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Checking import availability…",
|
||||
"host-not-running": "Start the Desktop-managed host before importing.",
|
||||
},
|
||||
actions: { import: "Import", importing: "Importing…", done: "Done" },
|
||||
unavailable: "Desktop import is unavailable.",
|
||||
complete: "Import complete.",
|
||||
failed: "Import failed.",
|
||||
description: "Import projects, settings, and worktrees from this Mac.",
|
||||
sheetTitle: "Import from {{source}}",
|
||||
confirmation:
|
||||
"Paseo will register valid repositories, merge supported project settings, and adopt or recreate ready worktrees. {{source}} data will not be changed.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Unable to check CLI install status.",
|
||||
installFailed: "Unable to install the Paseo CLI.",
|
||||
|
||||
@@ -1157,6 +1157,20 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Comprobando la disponibilidad de la importación…",
|
||||
"host-not-running": "Inicia el host gestionado por Desktop antes de importar.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Listo" },
|
||||
unavailable: "La importación de escritorio no está disponible.",
|
||||
complete: "Importación completada.",
|
||||
failed: "La importación ha fallado.",
|
||||
description: "Importa proyectos, ajustes y worktrees desde este Mac.",
|
||||
sheetTitle: "Importar desde {{source}}",
|
||||
confirmation:
|
||||
"Paseo registrará los repositorios válidos, combinará los ajustes de proyecto compatibles y adoptará o recreará los worktrees preparados. Los datos de {{source}} no se modificarán.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "No se puede verificar el estado de instalación deCLI.",
|
||||
installFailed: "No se puede instalar elPaseoCLI.",
|
||||
|
||||
@@ -1159,6 +1159,20 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
|
||||
installFailed: "Impossible d'installer lePaseoCLI.",
|
||||
|
||||
@@ -1132,6 +1132,20 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "インポートが利用可能か確認しています…",
|
||||
"host-not-running": "インポートする前にDesktop管理ホストを起動してください。",
|
||||
},
|
||||
actions: { import: "インポート", importing: "インポート中…", done: "完了" },
|
||||
unavailable: "デスクトップインポートは利用できません。",
|
||||
complete: "インポートが完了しました。",
|
||||
failed: "インポートに失敗しました。",
|
||||
description: "このMacからプロジェクト、設定、worktreeをインポートします。",
|
||||
sheetTitle: "{{source}}からインポート",
|
||||
confirmation:
|
||||
"Paseoは有効なリポジトリを登録し、対応するプロジェクト設定を統合して、準備済みのworktreeを採用または再作成します。{{source}}のデータは変更されません。",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "CLIのインストール状態を確認できません。",
|
||||
installFailed: "Paseo CLIをインストールできません。",
|
||||
|
||||
@@ -1144,6 +1144,20 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Verificando a disponibilidade da importação…",
|
||||
"host-not-running": "Inicie o host gerenciado pelo Desktop antes de importar.",
|
||||
},
|
||||
actions: { import: "Importar", importing: "Importando…", done: "Concluído" },
|
||||
unavailable: "A importação pelo aplicativo para desktop não está disponível.",
|
||||
complete: "Importação concluída.",
|
||||
failed: "Falha na importação.",
|
||||
description: "Importe projetos, configurações e worktrees deste Mac.",
|
||||
sheetTitle: "Importar do {{source}}",
|
||||
confirmation:
|
||||
"O Paseo registrará repositórios válidos, mesclará configurações de projeto compatíveis e adotará ou recriará worktrees prontos. Os dados do {{source}} não serão alterados.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Não foi possível verificar o status de instalação da CLI.",
|
||||
installFailed: "Não foi possível instalar a CLI do Paseo.",
|
||||
|
||||
@@ -1147,6 +1147,20 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "Проверка доступности импорта…",
|
||||
"host-not-running": "Запустите управляемый Desktop хост перед импортом.",
|
||||
},
|
||||
actions: { import: "Импортировать", importing: "Импорт…", done: "Готово" },
|
||||
unavailable: "Импорт в настольном приложении недоступен.",
|
||||
complete: "Импорт завершён.",
|
||||
failed: "Не удалось выполнить импорт.",
|
||||
description: "Импорт проектов, настроек и рабочих деревьев с этого Mac.",
|
||||
sheetTitle: "Импорт из {{source}}",
|
||||
confirmation:
|
||||
"Paseo зарегистрирует допустимые репозитории, объединит поддерживаемые настройки проектов и подключит или пересоздаст готовые рабочие деревья. Данные {{source}} не изменятся.",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "Невозможно проверить статус установки CLI.",
|
||||
installFailed: "Невозможно установить PaseoCLI.",
|
||||
|
||||
@@ -1103,6 +1103,20 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
import: {
|
||||
availability: {
|
||||
checking: "正在检查导入是否可用…",
|
||||
"host-not-running": "请先启动由桌面端管理的主机再导入。",
|
||||
},
|
||||
actions: { import: "导入", importing: "正在导入…", done: "完成" },
|
||||
unavailable: "桌面端导入不可用。",
|
||||
complete: "导入完成。",
|
||||
failed: "导入失败。",
|
||||
description: "从这台 Mac 导入项目、设置和工作树。",
|
||||
sheetTitle: "从 {{source}} 导入",
|
||||
confirmation:
|
||||
"Paseo 将注册有效仓库、合并支持的项目设置,并采用或重新创建已就绪的工作树。{{source}} 数据不会被修改。",
|
||||
},
|
||||
cli: {
|
||||
statusFailed: "无法检查 CLI 安装状态。",
|
||||
installFailed: "无法安装 Paseo CLI。",
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
parseConnectionOfferFromUrl,
|
||||
type ConnectionOffer,
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "ws";
|
||||
connectToDaemon as connectNodeClient,
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from "@getpaseo/client/node";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
import { resolveCliVersion } from "../version.js";
|
||||
|
||||
export {
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
};
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string;
|
||||
timeout?: number;
|
||||
@@ -28,26 +27,14 @@ export interface DaemonConnectionCommandError {
|
||||
details: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "localhost:6767";
|
||||
const DEFAULT_TIMEOUT = 15000;
|
||||
const PID_FILENAME = "paseo.pid";
|
||||
|
||||
type DaemonTarget =
|
||||
| {
|
||||
type: "tcp";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: "ipc";
|
||||
url: string;
|
||||
socketPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the daemon host from environment or options
|
||||
*/
|
||||
export function getDaemonHost(options?: ConnectOptions): string {
|
||||
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
|
||||
return (
|
||||
options?.host ?? process.env.PASEO_HOST ?? resolveDefaultDaemonHosts()[0] ?? "localhost:6767"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return resolveDefaultDaemonHosts(env)[0] ?? "localhost:6767";
|
||||
}
|
||||
|
||||
export function buildDaemonConnectionCommandError(options: {
|
||||
@@ -63,324 +50,16 @@ export function buildDaemonConnectionCommandError(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("tcp://")) {
|
||||
try {
|
||||
const parsed = parseConnectionUri(trimmed);
|
||||
const endpoint = normalizeHostPort(
|
||||
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
|
||||
);
|
||||
const query = new URLSearchParams();
|
||||
if (parsed.useTls) {
|
||||
query.set("ssl", "true");
|
||||
}
|
||||
if (parsed.password) {
|
||||
query.set("password", parsed.password);
|
||||
}
|
||||
const queryString = query.toString();
|
||||
const suffix = queryString ? `?${queryString}` : "";
|
||||
return `tcp://${endpoint}${suffix}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
trimmed.startsWith("unix://") ||
|
||||
trimmed.startsWith("pipe://") ||
|
||||
trimmed.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||
return `unix://${trimmed}`;
|
||||
}
|
||||
|
||||
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
|
||||
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return `127.0.0.1:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed.includes(":") ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function isIpcDaemonHost(host: string | null): host is string {
|
||||
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
|
||||
}
|
||||
|
||||
function isTcpDaemonHost(host: string | null): host is string {
|
||||
return host !== null && !isIpcDaemonHost(host);
|
||||
}
|
||||
|
||||
function readPidSocketTarget(paseoHome: string): string | null {
|
||||
const pidPath = path.join(paseoHome, PID_FILENAME);
|
||||
if (!existsSync(pidPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
|
||||
listen?: unknown;
|
||||
sockPath?: unknown;
|
||||
};
|
||||
if (typeof parsed.listen === "string") return parsed.listen;
|
||||
if (typeof parsed.sockPath === "string") return parsed.sockPath;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
|
||||
if (isIpcDaemonHost(directEnvHost)) {
|
||||
return directEnvHost;
|
||||
}
|
||||
|
||||
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
|
||||
if (isIpcDaemonHost(pidHost)) {
|
||||
return pidHost;
|
||||
}
|
||||
|
||||
const config = loadConfig(paseoHome, { env });
|
||||
const configuredHost = normalizeDaemonHost(config.listen);
|
||||
return isIpcDaemonHost(configuredHost) ? configuredHost : null;
|
||||
}
|
||||
|
||||
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
|
||||
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
|
||||
if (!isTcpDaemonHost(configuredHost)) {
|
||||
return null;
|
||||
}
|
||||
return configuredHost === "127.0.0.1:6767" ? null : configuredHost;
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const paseoHome = resolvePaseoHome(env);
|
||||
const candidates: string[] = [];
|
||||
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
|
||||
if (configuredIpcHost) {
|
||||
candidates.push(configuredIpcHost);
|
||||
}
|
||||
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
|
||||
if (configuredTcpHost) {
|
||||
candidates.push(configuredTcpHost);
|
||||
}
|
||||
candidates.push(DEFAULT_HOST);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
|
||||
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
||||
if (explicitHost) {
|
||||
return [explicitHost];
|
||||
}
|
||||
|
||||
return resolveDefaultDaemonHosts();
|
||||
}
|
||||
|
||||
function stripIpcPrefix(trimmed: string): string {
|
||||
if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim();
|
||||
if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
const trimmed = host.trim();
|
||||
if (
|
||||
trimmed.startsWith("unix://") ||
|
||||
trimmed.startsWith("pipe://") ||
|
||||
trimmed.startsWith("\\\\.\\pipe\\")
|
||||
) {
|
||||
const socketPath = stripIpcPrefix(trimmed);
|
||||
if (!socketPath) {
|
||||
throw new Error("Invalid IPC daemon target: missing socket path");
|
||||
}
|
||||
const isUnixSocket = trimmed.startsWith("unix://");
|
||||
return {
|
||||
type: "ipc",
|
||||
url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
|
||||
socketPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("tcp://")) {
|
||||
const parsed = parseConnectionUri(trimmed);
|
||||
const endpoint = normalizeHostPort(
|
||||
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
|
||||
);
|
||||
return {
|
||||
type: "tcp",
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tcp",
|
||||
url: `ws://${trimmed}/ws`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDaemonPassword(host: string): string | undefined {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.startsWith("tcp://")) {
|
||||
const fromUri = parseConnectionUri(trimmed).password;
|
||||
if (fromUri) return fromUri;
|
||||
}
|
||||
const fromEnv = process.env.PASEO_PASSWORD;
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
|
||||
): WebSocketLike => {
|
||||
return new WebSocket(url, options?.protocols, {
|
||||
headers: options?.headers,
|
||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||
}) as unknown as WebSocketLike;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and connect a daemon client
|
||||
* Returns the connected client or throws if connection fails
|
||||
*/
|
||||
async function tryConnectHost(
|
||||
host: string,
|
||||
password: string | undefined,
|
||||
clientId: string,
|
||||
timeout: number,
|
||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||
): Promise<{ client: DaemonClient } | { error: unknown }> {
|
||||
const target = resolveDaemonTarget(host);
|
||||
const client = new DaemonClient({
|
||||
url: target.url,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
password,
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (
|
||||
url: string,
|
||||
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) =>
|
||||
nodeWebSocketFactory(url, {
|
||||
headers: config?.headers,
|
||||
protocols: config?.protocols,
|
||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||
}),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
return { client };
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
async function connectViaRelayOffer(
|
||||
offer: ConnectionOffer,
|
||||
clientId: string,
|
||||
timeout: number,
|
||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||
): Promise<DaemonClient> {
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: offer.relay.endpoint,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
||||
});
|
||||
|
||||
const client = new DaemonClient({
|
||||
url,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (
|
||||
target: string,
|
||||
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lastError = client.lastError ? ` (${client.lastError})` : "";
|
||||
throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null {
|
||||
if (!host) return null;
|
||||
try {
|
||||
return parseConnectionOfferFromUrl(host);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
const clientId = await getOrCreateCliClientId();
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
|
||||
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
||||
const offer = parseHostOfferOrNull(explicitHost);
|
||||
if (offer) {
|
||||
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
|
||||
}
|
||||
|
||||
const hosts = resolveDaemonHostCandidates(options);
|
||||
|
||||
async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> {
|
||||
if (index >= hosts.length) {
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
|
||||
}
|
||||
const host = hosts[index];
|
||||
const password = resolveDaemonPassword(host);
|
||||
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
|
||||
if ("client" in result) {
|
||||
return result.client;
|
||||
}
|
||||
return tryNext(index + 1, result.error);
|
||||
}
|
||||
|
||||
return tryNext(0, null);
|
||||
return connectNodeClient({
|
||||
appVersion: resolveCliVersion(),
|
||||
clientId: await getOrCreateCliClientId(),
|
||||
clientType: "cli",
|
||||
host: options?.host,
|
||||
timeoutMs: options?.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to the daemon, returns null if connection fails
|
||||
*/
|
||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
||||
try {
|
||||
return await connectToDaemon(options);
|
||||
@@ -389,57 +68,21 @@ export async function tryConnectToDaemon(options?: ConnectOptions): Promise<Daem
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal agent type for ID resolution */
|
||||
interface AgentLike {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent ID from a partial ID or name.
|
||||
* Supports:
|
||||
* - Full ID match
|
||||
* - Prefix match (first N characters)
|
||||
* - Title/name match (case-insensitive)
|
||||
*
|
||||
* Returns the full agent ID if found, null otherwise.
|
||||
*/
|
||||
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
|
||||
if (!idOrName || agents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!idOrName || agents.length === 0) return null;
|
||||
const query = idOrName.toLowerCase();
|
||||
|
||||
// Try exact ID match first
|
||||
const exactMatch = agents.find((a) => a.id === idOrName);
|
||||
if (exactMatch) {
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
// Try ID prefix match
|
||||
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
|
||||
if (prefixMatches.length === 1 && prefixMatches[0]) {
|
||||
return prefixMatches[0].id;
|
||||
}
|
||||
|
||||
// Try title/name match (case-insensitive)
|
||||
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query);
|
||||
if (titleMatches.length === 1 && titleMatches[0]) {
|
||||
return titleMatches[0].id;
|
||||
}
|
||||
|
||||
// Try partial title match
|
||||
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query));
|
||||
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
|
||||
return partialTitleMatches[0].id;
|
||||
}
|
||||
|
||||
// If we have multiple prefix matches and no unique title match, return first prefix match
|
||||
const firstPrefixMatch = prefixMatches[0];
|
||||
if (firstPrefixMatch) {
|
||||
return firstPrefixMatch.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
const exact = agents.find((agent) => agent.id === idOrName);
|
||||
if (exact) return exact.id;
|
||||
const prefixes = agents.filter((agent) => agent.id.toLowerCase().startsWith(query));
|
||||
if (prefixes.length === 1) return prefixes[0]?.id ?? null;
|
||||
const exactTitle = agents.filter((agent) => agent.title?.toLowerCase() === query);
|
||||
if (exactTitle.length === 1) return exactTitle[0]?.id ?? null;
|
||||
const partialTitle = agents.filter((agent) => agent.title?.toLowerCase().includes(query));
|
||||
if (partialTitle.length === 1) return partialTitle[0]?.id ?? null;
|
||||
return prefixes[0]?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/node.d.ts",
|
||||
"default": "./dist/node.js"
|
||||
},
|
||||
"./internal/daemon-client": {
|
||||
"types": "./dist/daemon-client.d.ts",
|
||||
"default": "./dist/daemon-client.js"
|
||||
@@ -37,10 +41,12 @@
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
|
||||
131
packages/client/src/node.test.ts
Normal file
131
packages/client/src/node.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import {
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from "./node.js";
|
||||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of cleanup.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("discovers the configured local socket before TCP fallback", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-"));
|
||||
cleanup.push(paseoHome);
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo.sock" }),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }),
|
||||
);
|
||||
|
||||
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
|
||||
"unix:///tmp/paseo.sock",
|
||||
"127.0.0.1:7777",
|
||||
"localhost:6767",
|
||||
]);
|
||||
});
|
||||
|
||||
test("discovers the running daemon TCP address from its PID record", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-tcp-"));
|
||||
cleanup.push(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "127.0.0.1:7789" }),
|
||||
);
|
||||
|
||||
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
|
||||
"127.0.0.1:7789",
|
||||
"localhost:6767",
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizes the daemon's bare IPv6 loopback PID address", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-ipv6-"));
|
||||
cleanup.push(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: process.pid, listen: "::1:7789" }),
|
||||
);
|
||||
|
||||
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
|
||||
"[::1]:7789",
|
||||
"localhost:6767",
|
||||
]);
|
||||
expect(resolveDaemonTarget("::1:7789")).toEqual({
|
||||
type: "tcp",
|
||||
url: "ws://[::1]:7789/ws",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes TCP, Unix, pipe, and Windows path-shaped targets", () => {
|
||||
expect(normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=secret")).toBe(
|
||||
"tcp://Example.com:6767?ssl=true&password=secret",
|
||||
);
|
||||
expect(resolveDaemonTarget("unix:///tmp/paseo.sock")).toEqual({
|
||||
type: "ipc",
|
||||
url: "ws+unix:///tmp/paseo.sock:/ws",
|
||||
socketPath: "/tmp/paseo.sock",
|
||||
});
|
||||
expect(normalizeDaemonHost("C:\\Users\\fixture\\paseo.sock")).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps explicit and environment passwords process-local", () => {
|
||||
expect(resolveDaemonPassword("tcp://localhost:6767?password=query-secret", {})).toBe(
|
||||
"query-secret",
|
||||
);
|
||||
expect(resolveDaemonPassword("localhost:6767", { PASEO_PASSWORD: "env-secret" })).toBe(
|
||||
"env-secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the legacy PORT fallback when no listen setting exists", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-port-"));
|
||||
cleanup.push(paseoHome);
|
||||
|
||||
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PORT: "7788" })).toEqual([
|
||||
"127.0.0.1:7788",
|
||||
"localhost:6767",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips malformed discovered candidates and keeps the default fallback", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-malformed-"));
|
||||
cleanup.push(paseoHome);
|
||||
writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "tcp://bad" }));
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ daemon: { listen: "C:\\invalid\\socket" } }),
|
||||
);
|
||||
|
||||
expect(
|
||||
resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome, PASEO_LISTEN: "tcp://missing-port" }),
|
||||
).toEqual(["localhost:6767"]);
|
||||
});
|
||||
|
||||
test("ignores a stale PID listener before the configured daemon", () => {
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-stale-pid-"));
|
||||
cleanup.push(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "paseo.pid"),
|
||||
JSON.stringify({ pid: 2_147_483_647, listen: "127.0.0.1:7789" }),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ daemon: { listen: "127.0.0.1:7777" } }),
|
||||
);
|
||||
|
||||
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
|
||||
"127.0.0.1:7777",
|
||||
"localhost:6767",
|
||||
]);
|
||||
});
|
||||
445
packages/client/src/node.ts
Normal file
445
packages/client/src/node.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { slugify } from "@getpaseo/protocol/branch-slug";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
parseConnectionOfferFromUrl,
|
||||
type ConnectionOffer,
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
import type { PaseoConfigRaw, PaseoConfigRevision } from "@getpaseo/protocol/messages";
|
||||
import { WebSocket } from "ws";
|
||||
import { DaemonClient, type WebSocketLike } from "./daemon-client.js";
|
||||
|
||||
const DEFAULT_HOST = "localhost:6767";
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface NodeHostConnectionOptions {
|
||||
appVersion: string;
|
||||
clientId?: string;
|
||||
clientType?: "cli" | "mcp";
|
||||
env?: NodeJS.ProcessEnv;
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface HostAutomation {
|
||||
addProject(rootPath: string): Promise<void>;
|
||||
openCheckout(path: string): Promise<void>;
|
||||
readProjectConfig(rootPath: string): Promise<{
|
||||
config: PaseoConfigRaw | null;
|
||||
revision: PaseoConfigRevision | null;
|
||||
}>;
|
||||
writeProjectConfig(input: {
|
||||
rootPath: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
}): Promise<void>;
|
||||
ensureCheckout(input: {
|
||||
rootPath: string;
|
||||
refName: string;
|
||||
directoryName: string;
|
||||
}): Promise<{ path: string; created: boolean }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PersistedHostConfig {
|
||||
daemon?: { listen?: unknown };
|
||||
}
|
||||
|
||||
type DaemonTarget = { type: "tcp"; url: string } | { type: "ipc"; url: string; socketPath: string };
|
||||
|
||||
export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const configured = env.PASEO_HOME ?? "~/.paseo";
|
||||
const expanded =
|
||||
configured === "~" ? os.homedir() : configured.replace(/^~\//, `${os.homedir()}/`);
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
if (value.startsWith("tcp://")) {
|
||||
try {
|
||||
const parsed = parseConnectionUri(value);
|
||||
const endpoint = normalizeHostPort(
|
||||
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
|
||||
);
|
||||
const query = new URLSearchParams();
|
||||
if (parsed.useTls) query.set("ssl", "true");
|
||||
if (parsed.password) query.set("password", parsed.password);
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
||||
return `tcp://${endpoint}${suffix}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (value.startsWith("unix://") || value.startsWith("pipe://")) return value;
|
||||
if (value.startsWith("\\\\.\\pipe\\")) return `pipe://${value}`;
|
||||
if (value.startsWith("/") || value.startsWith("~")) return `unix://${value}`;
|
||||
if (/^[A-Za-z]:[/\\]/.test(value)) return null;
|
||||
if (/^\d+$/.test(value)) return `127.0.0.1:${value}`;
|
||||
const ipv6Loopback = normalizeBareIpv6Loopback(value);
|
||||
if (ipv6Loopback) return ipv6Loopback;
|
||||
if (value.startsWith("::1:")) return null;
|
||||
return value.includes(":") ? value : null;
|
||||
}
|
||||
|
||||
function readConfiguredListen(paseoHome: string): string | null {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
if (!existsSync(configPath)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as PersistedHostConfig;
|
||||
return typeof parsed.daemon?.listen === "string" ? parsed.daemon.listen : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPidListen(paseoHome: string): string | null {
|
||||
const pidPath = path.join(paseoHome, "paseo.pid");
|
||||
if (!existsSync(pidPath)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf8")) as {
|
||||
pid?: unknown;
|
||||
listen?: unknown;
|
||||
sockPath?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof parsed.pid !== "number" ||
|
||||
!Number.isInteger(parsed.pid) ||
|
||||
!isProcessRunning(parsed.pid)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.listen === "string") return parsed.listen;
|
||||
return typeof parsed.sockPath === "string" ? parsed.sockPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const paseoHome = resolvePaseoHome(env);
|
||||
const direct = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
|
||||
const pid = normalizeDaemonHost(readPidListen(paseoHome) ?? "");
|
||||
const configuredListen = readConfiguredListen(paseoHome);
|
||||
const configured = normalizeDaemonHost(configuredListen ?? "");
|
||||
const port =
|
||||
!env.PASEO_LISTEN && !configuredListen && /^\d+$/.test(env.PORT ?? "")
|
||||
? normalizeDaemonHost(env.PORT ?? "")
|
||||
: null;
|
||||
const rawCandidates = [direct, pid, configured].filter(
|
||||
(candidate): candidate is string => candidate !== null,
|
||||
);
|
||||
const ipc = rawCandidates.filter(
|
||||
(candidate) => candidate.startsWith("unix://") || candidate.startsWith("pipe://"),
|
||||
);
|
||||
const tcp = [direct, pid, configured, port].filter(
|
||||
(candidate): candidate is string =>
|
||||
candidate !== null &&
|
||||
!candidate.startsWith("unix://") &&
|
||||
!candidate.startsWith("pipe://") &&
|
||||
candidate !== "127.0.0.1:6767",
|
||||
);
|
||||
const candidates = [...ipc, ...tcp];
|
||||
candidates.push(DEFAULT_HOST);
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
|
||||
export function resolveDaemonTarget(host: string): DaemonTarget {
|
||||
const value = host.trim();
|
||||
const isIpc =
|
||||
value.startsWith("unix://") || value.startsWith("pipe://") || value.startsWith("\\\\.\\pipe\\");
|
||||
if (isIpc) {
|
||||
const socketPath = value.replace(/^(?:unix|pipe):\/\//, "").trim();
|
||||
if (!socketPath) throw new Error("Invalid IPC daemon target: missing socket path");
|
||||
return {
|
||||
type: "ipc",
|
||||
url: value.startsWith("unix://") ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
|
||||
socketPath,
|
||||
};
|
||||
}
|
||||
if (value.startsWith("tcp://")) {
|
||||
const parsed = parseConnectionUri(value);
|
||||
const endpoint = normalizeHostPort(
|
||||
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
|
||||
);
|
||||
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }) };
|
||||
}
|
||||
const endpoint = normalizeBareIpv6Loopback(value) ?? normalizeHostPort(value);
|
||||
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: false }) };
|
||||
}
|
||||
|
||||
function normalizeBareIpv6Loopback(value: string): string | null {
|
||||
const match = value.match(/^::1:(\d{1,5})$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return normalizeHostPort(`[::1]:${match[1]}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDaemonPassword(
|
||||
host: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
if (host.startsWith("tcp://")) {
|
||||
const password = parseConnectionUri(host).password;
|
||||
if (password) return password;
|
||||
}
|
||||
return env.PASEO_PASSWORD?.trim() || undefined;
|
||||
}
|
||||
|
||||
function createWebSocket(
|
||||
target: DaemonTarget,
|
||||
): (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => WebSocketLike {
|
||||
return (url, options) =>
|
||||
new WebSocket(url, options?.protocols, {
|
||||
headers: options?.headers,
|
||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||
}) as unknown as WebSocketLike;
|
||||
}
|
||||
|
||||
async function connectCandidate(
|
||||
host: string,
|
||||
options: Required<
|
||||
Pick<NodeHostConnectionOptions, "appVersion" | "clientId" | "clientType" | "timeoutMs">
|
||||
> & {
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Promise<DaemonClient> {
|
||||
const target = resolveDaemonTarget(host);
|
||||
const client = new DaemonClient({
|
||||
url: target.url,
|
||||
clientId: options.clientId,
|
||||
clientType: options.clientType,
|
||||
appVersion: options.appVersion,
|
||||
password: resolveDaemonPassword(host, options.env),
|
||||
connectTimeoutMs: options.timeoutMs,
|
||||
webSocketFactory: createWebSocket(target),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectRelay(
|
||||
offer: ConnectionOffer,
|
||||
options: Required<
|
||||
Pick<NodeHostConnectionOptions, "appVersion" | "clientId" | "clientType" | "timeoutMs">
|
||||
>,
|
||||
): Promise<DaemonClient> {
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: offer.relay.endpoint,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url,
|
||||
clientId: options.clientId,
|
||||
clientType: options.clientType,
|
||||
appVersion: options.appVersion,
|
||||
connectTimeoutMs: options.timeoutMs,
|
||||
webSocketFactory: createWebSocket({ type: "tcp", url }),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToDaemon(options: NodeHostConnectionOptions): Promise<DaemonClient> {
|
||||
const env = options.env ?? process.env;
|
||||
let hosts: string[];
|
||||
if (options.host) hosts = [options.host];
|
||||
else if (env.PASEO_HOST) hosts = [env.PASEO_HOST];
|
||||
else hosts = resolveDefaultDaemonHosts(env);
|
||||
const identity = {
|
||||
appVersion: options.appVersion,
|
||||
clientId: options.clientId ?? `node-${randomUUID()}`,
|
||||
clientType: options.clientType ?? ("cli" as const),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
if (hosts.length === 1) {
|
||||
const offer = parseOffer(hosts[0]);
|
||||
if (offer) return connectRelay(offer, identity);
|
||||
}
|
||||
let lastError: unknown = new Error("No Paseo daemon targets were discovered.");
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
return await connectCandidate(host, {
|
||||
...identity,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function parseOffer(host: string): ConnectionOffer | null {
|
||||
try {
|
||||
return parseConnectionOfferFromUrl(host);
|
||||
} catch (error) {
|
||||
const isPaseoUrl = host.startsWith("paseo://") || host.includes("/connect#");
|
||||
if (isPaseoUrl) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DaemonHostAutomation implements HostAutomation {
|
||||
constructor(private readonly client: DaemonClient) {}
|
||||
|
||||
async addProject(rootPath: string): Promise<void> {
|
||||
const result = await this.client.addProject(rootPath);
|
||||
if (!result.project) throw new Error(result.error ?? `Unable to add ${rootPath}`);
|
||||
}
|
||||
|
||||
async openCheckout(checkoutPath: string): Promise<void> {
|
||||
const result = await this.client.openProject(checkoutPath);
|
||||
if (!result.workspace) throw new Error(result.error ?? `Unable to open ${checkoutPath}`);
|
||||
}
|
||||
|
||||
async readProjectConfig(rootPath: string) {
|
||||
const result = await this.client.readProjectConfig(rootPath);
|
||||
if (!result.ok) throw new Error(`Unable to read project config: ${result.error.code}`);
|
||||
return { config: result.config, revision: result.revision };
|
||||
}
|
||||
|
||||
async writeProjectConfig(input: {
|
||||
rootPath: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
}): Promise<void> {
|
||||
const result = await this.client.writeProjectConfig({
|
||||
repoRoot: input.rootPath,
|
||||
config: input.config,
|
||||
expectedRevision: input.expectedRevision,
|
||||
});
|
||||
if (!result.ok) throw new Error(`Unable to write project config: ${result.error.code}`);
|
||||
}
|
||||
|
||||
async ensureCheckout(input: {
|
||||
rootPath: string;
|
||||
refName: string;
|
||||
directoryName: string;
|
||||
}): Promise<{ path: string; created: boolean }> {
|
||||
const refName = input.refName.replace(/^refs\/heads\//, "");
|
||||
if (!refName) throw new Error("Checkout branch is required");
|
||||
const directorySlug = slugify(input.directoryName);
|
||||
if (!directorySlug)
|
||||
throw new Error(`Unable to derive a checkout name from ${input.directoryName}`);
|
||||
const listed = await this.client.getPaseoWorktreeList({ repoRoot: input.rootPath });
|
||||
if (listed.error)
|
||||
throw new Error(`Unable to inspect existing checkouts: ${listed.error.message}`);
|
||||
const existing = listed.worktrees.find(
|
||||
(worktree) => path.basename(worktree.worktreePath) === directorySlug,
|
||||
);
|
||||
if (existing && existing.branchName !== refName) {
|
||||
throw new Error(
|
||||
`Checkout name ${directorySlug} is already used by branch ${existing.branchName ?? "unknown"}.`,
|
||||
);
|
||||
}
|
||||
if (existing && existsSync(existing.worktreePath)) {
|
||||
const opened = await this.client.openProject(existing.worktreePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Unable to open ${existing.worktreePath}`);
|
||||
}
|
||||
return { path: existing.worktreePath, created: false };
|
||||
}
|
||||
|
||||
await removeMissingCheckoutRegistration(input.rootPath, refName);
|
||||
|
||||
const result = await this.client.createPaseoWorktree({
|
||||
cwd: input.rootPath,
|
||||
worktreeSlug: directorySlug,
|
||||
action: "checkout",
|
||||
refName,
|
||||
});
|
||||
const checkoutPath = result.workspace?.workspaceDirectory;
|
||||
if (!checkoutPath) throw new Error(result.error ?? `Unable to check out ${refName}`);
|
||||
return { path: checkoutPath, created: true };
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMissingCheckoutRegistration(rootPath: string, refName: string): Promise<void> {
|
||||
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: rootPath,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const expectedBranch = `refs/heads/${refName.replace(/^refs\/heads\//, "")}`;
|
||||
const stale = parseGitWorktreeList(stdout).find(
|
||||
(worktree) => worktree.branch === expectedBranch && !existsSync(worktree.path),
|
||||
);
|
||||
if (!stale) return;
|
||||
|
||||
// ensureCheckout is the explicit repair boundary: remove only the missing
|
||||
// registration that blocks the requested branch, never unrelated worktrees.
|
||||
await execFileAsync("git", ["worktree", "remove", "--force", stale.path], {
|
||||
cwd: rootPath,
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitWorktreeList(stdout: string): Array<{ path: string; branch: string | null }> {
|
||||
return stdout
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.flatMap((entry) => {
|
||||
const worktreePath = entry.match(/^worktree (.+)$/m)?.[1];
|
||||
if (!worktreePath) return [];
|
||||
return [{ path: worktreePath, branch: entry.match(/^branch (.+)$/m)?.[1] ?? null }];
|
||||
});
|
||||
}
|
||||
|
||||
export async function connectHostAutomation(
|
||||
options: NodeHostConnectionOptions,
|
||||
): Promise<HostAutomation> {
|
||||
const client = await connectToDaemon(options);
|
||||
if (client.getLastServerInfoMessage()?.features?.hostAutomation !== true) {
|
||||
await client.close();
|
||||
throw new Error(
|
||||
"This Paseo host does not support project import. Update the host to use this.",
|
||||
);
|
||||
}
|
||||
return new DaemonHostAutomation(client);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"capture-harness": "./capture-harness/run.sh",
|
||||
"test:import-electron": "npm --prefix ../.. run test:e2e:import-electron --workspace=@getpaseo/app",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs",
|
||||
@@ -27,6 +28,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/import": "0.1.0-beta.2",
|
||||
"@getpaseo/server": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
@@ -34,6 +36,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
|
||||
@@ -232,7 +232,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveDesktopAppVersion(): string {
|
||||
export function resolveDesktopAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("desktop packaging", () => {
|
||||
for (const required of ["@getpaseo/cli", "@getpaseo/server"]) {
|
||||
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
|
||||
}
|
||||
expect(deps["@getpaseo/import"]).toBe("0.1.0-beta.2");
|
||||
});
|
||||
|
||||
it("launches the packaged macOS CLI through Helper instead of the main app executable", () => {
|
||||
|
||||
66
packages/desktop/src/integrations/imports/ipc.ts
Normal file
66
packages/desktop/src/integrations/imports/ipc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { connectHostAutomation } from "@getpaseo/client/node";
|
||||
import { runImport } from "@getpaseo/import";
|
||||
import { ipcMain } from "electron";
|
||||
import {
|
||||
resolveDesktopAppVersion,
|
||||
resolveDesktopDaemonStatus,
|
||||
} from "../../daemon/daemon-manager.js";
|
||||
import { DesktopImportRunner, type ImportTarget } from "./runner.js";
|
||||
|
||||
const imports = new DesktopImportRunner({
|
||||
sources: new Map(process.platform === "darwin" ? [["conductor", { kind: "conductor" }]] : []),
|
||||
runImport,
|
||||
connect: (target) =>
|
||||
connectHostAutomation({
|
||||
appVersion: target.appVersion,
|
||||
host: target.listen ?? undefined,
|
||||
env: { PASEO_HOME: target.home },
|
||||
}),
|
||||
getTarget: async (): Promise<ImportTarget> => {
|
||||
const status = await resolveDesktopDaemonStatus();
|
||||
return {
|
||||
status: status.status,
|
||||
desktopManaged: status.desktopManaged,
|
||||
listen: status.listen,
|
||||
home: status.home,
|
||||
appVersion: resolveDesktopAppVersion(),
|
||||
daemonVersion: status.version,
|
||||
passwordProtected: Boolean(process.env.PASEO_PASSWORD?.trim()) || hasPassword(status.home),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function registerImportIpc(): void {
|
||||
ipcMain.handle("paseo:imports:run", async (event, input: unknown) => {
|
||||
const source = readSource(input);
|
||||
return {
|
||||
runId: await imports.run(source, (output) => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send("paseo:imports:output", output);
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function readSource(input: unknown): string {
|
||||
if (typeof input !== "object" || input === null || !("source" in input)) {
|
||||
throw new Error("Import source is required.");
|
||||
}
|
||||
const source = (input as { source?: unknown }).source;
|
||||
if (typeof source !== "string") throw new Error("Import source is required.");
|
||||
return source;
|
||||
}
|
||||
|
||||
function hasPassword(paseoHome: string): boolean {
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
if (!existsSync(configPath)) return false;
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configPath, "utf8")) as {
|
||||
daemon?: { auth?: { password?: unknown } };
|
||||
};
|
||||
return typeof config.daemon?.auth?.password === "string";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
186
packages/desktop/src/integrations/imports/runner.test.ts
Normal file
186
packages/desktop/src/integrations/imports/runner.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { ImportResult, PaseoImportHost, RunImportOptions } from "@getpaseo/import";
|
||||
import { expect, test } from "vitest";
|
||||
import { DesktopImportRunner, type DesktopImportOutput, type ImportTarget } from "./runner.js";
|
||||
|
||||
const eligibleTarget: ImportTarget = {
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
listen: "unix:///tmp/paseo.sock",
|
||||
home: "/tmp/paseo-home",
|
||||
appVersion: "0.1.110",
|
||||
daemonVersion: "0.1.110",
|
||||
passwordProtected: false,
|
||||
};
|
||||
|
||||
test("calls the installed library and streams typed progress", async () => {
|
||||
const outputs: DesktopImportOutput[] = [];
|
||||
const host = createHost();
|
||||
const runner = createRunner({
|
||||
host,
|
||||
runImport: async (options) => {
|
||||
options.onEvent?.({ level: "info", message: "Found one project." });
|
||||
return emptyResult();
|
||||
},
|
||||
});
|
||||
|
||||
const runId = await runner.run("source-fixture", (output) => outputs.push(output));
|
||||
await waitForCompletion(outputs);
|
||||
|
||||
expect(outputs).toEqual([
|
||||
{
|
||||
runId,
|
||||
type: "event",
|
||||
event: { level: "info", message: "Found one project." },
|
||||
},
|
||||
{ runId, type: "status", succeeded: true },
|
||||
]);
|
||||
expect(host.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("allows only one import at a time", async () => {
|
||||
let finish: (() => void) | undefined;
|
||||
const runner = createRunner({
|
||||
runImport: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve(emptyResult());
|
||||
}),
|
||||
});
|
||||
|
||||
await runner.run("source-fixture", () => undefined);
|
||||
await expect(runner.run("source-fixture", () => undefined)).rejects.toThrow(
|
||||
"An import is already running.",
|
||||
);
|
||||
finish?.();
|
||||
await waitUntil(() => runner.run("source-fixture", () => undefined));
|
||||
});
|
||||
|
||||
test("reports library failures and closes the host", async () => {
|
||||
const outputs: DesktopImportOutput[] = [];
|
||||
const host = createHost();
|
||||
const runner = createRunner({
|
||||
host,
|
||||
runImport: async () => {
|
||||
throw new Error("Source database is unreadable.");
|
||||
},
|
||||
});
|
||||
|
||||
const runId = await runner.run("source-fixture", (output) => outputs.push(output));
|
||||
await waitForCompletion(outputs);
|
||||
|
||||
expect(outputs).toEqual([
|
||||
{
|
||||
runId,
|
||||
type: "event",
|
||||
event: { level: "error", message: "Source database is unreadable." },
|
||||
},
|
||||
{ runId, type: "status", succeeded: false },
|
||||
]);
|
||||
expect(host.closed).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
{ ...eligibleTarget, passwordProtected: true },
|
||||
"Import is unavailable while the local host is password-protected.",
|
||||
],
|
||||
[
|
||||
{ ...eligibleTarget, status: "stopped" as const },
|
||||
"Import requires the running Paseo Desktop-managed host.",
|
||||
],
|
||||
[
|
||||
{ ...eligibleTarget, daemonVersion: "0.1.109" },
|
||||
"Update the Desktop-managed host before importing.",
|
||||
],
|
||||
] as const)("refuses an ineligible import target", async (target, message) => {
|
||||
const runner = createRunner({ target });
|
||||
|
||||
await expect(runner.run("source-fixture", () => undefined)).rejects.toThrow(message);
|
||||
});
|
||||
|
||||
test("accepts a Desktop-managed host without inferring ownership from its listen address", async () => {
|
||||
const runner = createRunner({
|
||||
target: { ...eligibleTarget, listen: "0.0.0.0:6767" },
|
||||
});
|
||||
|
||||
await expect(runner.run("source-fixture", () => undefined)).resolves.toBe("run-1");
|
||||
});
|
||||
|
||||
test("rejects an unregistered source before target lookup or connection", async () => {
|
||||
let targetRead = false;
|
||||
let connected = false;
|
||||
const runner = new DesktopImportRunner({
|
||||
sources: new Map(),
|
||||
getTarget: async () => {
|
||||
targetRead = true;
|
||||
return eligibleTarget;
|
||||
},
|
||||
connect: async () => {
|
||||
connected = true;
|
||||
return createHost();
|
||||
},
|
||||
runImport: async () => emptyResult(),
|
||||
});
|
||||
|
||||
await expect(runner.run("unknown-source", () => undefined)).rejects.toThrow(
|
||||
"Unsupported import source: unknown-source",
|
||||
);
|
||||
expect(targetRead).toBe(false);
|
||||
expect(connected).toBe(false);
|
||||
});
|
||||
|
||||
function createRunner(options: {
|
||||
target?: ImportTarget;
|
||||
host?: TestHost;
|
||||
runImport?: (options: RunImportOptions) => Promise<ImportResult>;
|
||||
}): DesktopImportRunner {
|
||||
return new DesktopImportRunner({
|
||||
sources: new Map([["source-fixture", { kind: "conductor" }]]),
|
||||
getTarget: async () => options.target ?? eligibleTarget,
|
||||
connect: async () => options.host ?? createHost(),
|
||||
runImport: options.runImport ?? (async () => emptyResult()),
|
||||
createRunId: () => "run-1",
|
||||
});
|
||||
}
|
||||
|
||||
interface TestHost extends PaseoImportHost {
|
||||
closed: boolean;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function createHost(): TestHost {
|
||||
return {
|
||||
closed: false,
|
||||
addProject: async () => undefined,
|
||||
openCheckout: async () => undefined,
|
||||
readProjectConfig: async () => ({ config: null, revision: null }),
|
||||
writeProjectConfig: async () => undefined,
|
||||
ensureCheckout: async () => ({ path: "", created: false }),
|
||||
async close() {
|
||||
this.closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function emptyResult() {
|
||||
return {
|
||||
inventory: { projects: [], skippedSettings: [] },
|
||||
notices: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForCompletion(outputs: DesktopImportOutput[]): Promise<void> {
|
||||
await waitUntil(() => outputs.some((output) => output.type === "status"));
|
||||
}
|
||||
|
||||
async function waitUntil<T>(read: () => T | Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const value = await read();
|
||||
if (value) return value;
|
||||
} catch (error) {
|
||||
if (attempt === 19) throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error("Expected import state was not reached.");
|
||||
}
|
||||
110
packages/desktop/src/integrations/imports/runner.ts
Normal file
110
packages/desktop/src/integrations/imports/runner.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ImportEvent,
|
||||
ImportResult,
|
||||
ImportSourceInput,
|
||||
PaseoImportHost,
|
||||
RunImportOptions,
|
||||
} from "@getpaseo/import";
|
||||
|
||||
export interface ImportTarget {
|
||||
status: "starting" | "running" | "stopped" | "errored";
|
||||
desktopManaged: boolean;
|
||||
listen: string | null;
|
||||
home: string;
|
||||
appVersion: string;
|
||||
daemonVersion: string | null;
|
||||
passwordProtected: boolean;
|
||||
}
|
||||
|
||||
export type DesktopImportOutput =
|
||||
| { runId: string; type: "event"; event: ImportEvent }
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
type ConnectedImportHost = PaseoImportHost & { close(): Promise<void> };
|
||||
|
||||
interface ImportRunnerDependencies {
|
||||
sources: ReadonlyMap<string, ImportSourceInput>;
|
||||
getTarget(): Promise<ImportTarget>;
|
||||
connect(target: ImportTarget): Promise<ConnectedImportHost>;
|
||||
runImport(options: RunImportOptions): Promise<ImportResult>;
|
||||
createRunId?(): string;
|
||||
}
|
||||
|
||||
export class DesktopImportRunner {
|
||||
private activeRunId: string | null = null;
|
||||
|
||||
constructor(private readonly dependencies: ImportRunnerDependencies) {}
|
||||
|
||||
async run(sourceId: string, emit: (output: DesktopImportOutput) => void): Promise<string> {
|
||||
const source = this.requireSource(sourceId);
|
||||
if (this.activeRunId) throw new Error("An import is already running.");
|
||||
this.activeRunId = "starting";
|
||||
try {
|
||||
const target = await this.dependencies.getTarget();
|
||||
assertEligibleTarget(target);
|
||||
const host = await this.dependencies.connect(target);
|
||||
const runId = this.dependencies.createRunId?.() ?? randomUUID();
|
||||
this.activeRunId = runId;
|
||||
void this.execute({ runId, source, host, emit });
|
||||
return runId;
|
||||
} catch (error) {
|
||||
this.activeRunId = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async execute(input: {
|
||||
runId: string;
|
||||
source: ImportSourceInput;
|
||||
host: ConnectedImportHost;
|
||||
emit(output: DesktopImportOutput): void;
|
||||
}): Promise<void> {
|
||||
let succeeded = false;
|
||||
try {
|
||||
const result = await this.dependencies.runImport({
|
||||
source: input.source,
|
||||
host: input.host,
|
||||
onEvent: (event) => input.emit({ runId: input.runId, type: "event", event }),
|
||||
});
|
||||
succeeded = !result.notices.some((notice) => notice.level === "error");
|
||||
} catch (error) {
|
||||
input.emit({
|
||||
runId: input.runId,
|
||||
type: "event",
|
||||
event: {
|
||||
level: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await input.host.close().catch(() => undefined);
|
||||
this.activeRunId = null;
|
||||
input.emit({ runId: input.runId, type: "status", succeeded });
|
||||
}
|
||||
}
|
||||
|
||||
private requireSource(sourceId: string): ImportSourceInput {
|
||||
const source = this.dependencies.sources.get(sourceId);
|
||||
if (!source) {
|
||||
throw new Error(`Unsupported import source: ${sourceId}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEligibleTarget(target: ImportTarget): void {
|
||||
if (target.status !== "running" || !target.desktopManaged) {
|
||||
throw new Error("Import requires the running Paseo Desktop-managed host.");
|
||||
}
|
||||
if (target.passwordProtected) {
|
||||
throw new Error("Import is unavailable while the local host is password-protected.");
|
||||
}
|
||||
if (normalizeVersion(target.daemonVersion) !== normalizeVersion(target.appVersion)) {
|
||||
throw new Error("Update the Desktop-managed host before importing.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVersion(version: string | null): string | null {
|
||||
return version?.trim().replace(/^v/i, "") || null;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ import { runDesktopStartup } from "./desktop-startup.js";
|
||||
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
|
||||
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
|
||||
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
|
||||
import { registerImportIpc } from "./integrations/imports/ipc.js";
|
||||
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
@@ -881,6 +882,7 @@ async function bootstrap(): Promise<void> {
|
||||
registerOpenerHandlers();
|
||||
registerEditorTargetHandlers();
|
||||
registerBrowserAutomationIpc();
|
||||
registerImportIpc();
|
||||
|
||||
// In-app "Open in new window": opens a window that lands on the given project
|
||||
// via the same open-project flow as a CLI launch (no move, no ownership).
|
||||
|
||||
@@ -17,6 +17,14 @@ interface AttachedBrowserRegistration {
|
||||
webContentsId: number;
|
||||
}
|
||||
|
||||
type DesktopImportOutput =
|
||||
| {
|
||||
runId: string;
|
||||
type: "event";
|
||||
event: { level: "info" | "warning" | "error"; message: string };
|
||||
}
|
||||
| { runId: string; type: "status"; succeeded: boolean };
|
||||
|
||||
contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
platform: process.platform,
|
||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||
@@ -34,6 +42,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
});
|
||||
},
|
||||
},
|
||||
imports: {
|
||||
run: (input: { source: string }) =>
|
||||
ipcRenderer.invoke("paseo:imports:run", input) as Promise<{ runId: string }>,
|
||||
onOutput: (handler: (output: DesktopImportOutput) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, output: DesktopImportOutput) => {
|
||||
handler(output);
|
||||
};
|
||||
ipcRenderer.on("paseo:imports:output", listener);
|
||||
return () => ipcRenderer.removeListener("paseo:imports:output", listener);
|
||||
},
|
||||
},
|
||||
window: {
|
||||
openNew: (options?: { pendingOpenProjectPath?: string | null }) =>
|
||||
ipcRenderer.invoke("paseo:window:openNew", options),
|
||||
|
||||
@@ -2721,6 +2721,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111.
|
||||
hostAutomation: z.boolean().optional(),
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/import-host-automation.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
"test:integration:local": "vitest run local.e2e.test.ts",
|
||||
|
||||
@@ -1609,6 +1609,7 @@ export async function createPaseoDaemon(
|
||||
detachAgentStoragePersistence();
|
||||
await agentStorage.flush().catch(() => undefined);
|
||||
await providerSnapshotManager.shutdown();
|
||||
workspaceGitService.dispose();
|
||||
terminalManager.killAll();
|
||||
speechService.stop();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
|
||||
@@ -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 { Writable } from "node:stream";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon } from "@getpaseo/client/node";
|
||||
|
||||
const nodeMajor = Number((process.versions.node ?? "0").split(".")[0] ?? "0");
|
||||
const shouldRunRelayE2e = process.env.FORCE_RELAY_E2E === "1" || nodeMajor < 25;
|
||||
@@ -172,7 +173,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
const startRelay = async () => {
|
||||
relayStdoutLines = [];
|
||||
relayPort = await getAvailablePort();
|
||||
const relayDir = path.resolve(process.cwd(), "../relay");
|
||||
const relayDir = fileURLToPath(new URL("../../../../relay", import.meta.url));
|
||||
relayProcess = spawn(
|
||||
"npx",
|
||||
[
|
||||
@@ -362,6 +363,41 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
||||
}
|
||||
}, 90000);
|
||||
|
||||
test("public Node connector follows a real encrypted relay offer", async () => {
|
||||
await startRelay();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
listen: "127.0.0.1",
|
||||
relayEnabled: true,
|
||||
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
});
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | null = null;
|
||||
try {
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
|
||||
client = await connectToDaemon({
|
||||
appVersion: "0.1.110",
|
||||
clientId: "public-node-relay-e2e",
|
||||
env: {},
|
||||
host: offerUrl,
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
|
||||
expect((await client.fetchAgents()).entries).toEqual([]);
|
||||
} finally {
|
||||
await client?.close();
|
||||
await daemon.close();
|
||||
await stopRelay();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
|
||||
@@ -838,6 +838,7 @@ export class Session {
|
||||
emit: (msg) => this.emit(msg),
|
||||
},
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.daemonSession = new DaemonSession({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
|
||||
import type { PersistedProjectRecord } from "../../workspace-registry.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
|
||||
import type { SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -33,12 +33,37 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers
|
||||
};
|
||||
}
|
||||
|
||||
function makeSubsystem(records: PersistedProjectRecord[]) {
|
||||
function workspaceRecord(
|
||||
projectId: string,
|
||||
cwd: string,
|
||||
archivedAt: string | null = null,
|
||||
): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId: `workspace:${cwd}`,
|
||||
projectId,
|
||||
cwd,
|
||||
kind: "worktree",
|
||||
displayName: "Workspace",
|
||||
branch: "feature",
|
||||
worktreeRoot: cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSubsystem(
|
||||
records: PersistedProjectRecord[],
|
||||
workspaces: PersistedWorkspaceRecord[] = [],
|
||||
) {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
|
||||
const subsystem = new ProjectConfigSession({
|
||||
host,
|
||||
projectRegistry: { list: async () => records },
|
||||
workspaceRegistry: { list: async () => workspaces },
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
return { subsystem, emitted };
|
||||
@@ -179,6 +204,92 @@ describe("ProjectConfigSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("read and write accept an active workspace belonging to an active project", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
const workspaceRoot = makeRoot();
|
||||
const project = projectRecord(repoRoot);
|
||||
const { subsystem, emitted } = makeSubsystem(
|
||||
[project],
|
||||
[workspaceRecord(project.projectId, workspaceRoot)],
|
||||
);
|
||||
|
||||
await subsystem.handleWriteProjectConfigRequest({
|
||||
type: "write_project_config_request",
|
||||
requestId: "workspace-write-1",
|
||||
repoRoot: workspaceRoot,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
expectedRevision: null,
|
||||
});
|
||||
await subsystem.handleReadProjectConfigRequest({
|
||||
type: "read_project_config_request",
|
||||
requestId: "workspace-read-1",
|
||||
repoRoot: workspaceRoot,
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "write_project_config_response",
|
||||
payload: expect.objectContaining({ ok: true, repoRoot: workspaceRoot }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "read_project_config_response",
|
||||
payload: expect.objectContaining({
|
||||
ok: true,
|
||||
repoRoot: workspaceRoot,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("read rejects archived workspaces and workspaces owned by archived projects", async () => {
|
||||
const activeRepoRoot = makeRoot();
|
||||
const archivedRepoRoot = makeRoot();
|
||||
const archivedWorkspaceRoot = makeRoot();
|
||||
const orphanedWorkspaceRoot = makeRoot();
|
||||
const activeProject = projectRecord(activeRepoRoot);
|
||||
const archivedProject = projectRecord(archivedRepoRoot, "2026-01-02T00:00:00.000Z");
|
||||
const { subsystem, emitted } = makeSubsystem(
|
||||
[activeProject, archivedProject],
|
||||
[
|
||||
workspaceRecord(activeProject.projectId, archivedWorkspaceRoot, "2026-01-02T00:00:00.000Z"),
|
||||
workspaceRecord(archivedProject.projectId, orphanedWorkspaceRoot),
|
||||
],
|
||||
);
|
||||
|
||||
for (const [requestId, repoRoot] of [
|
||||
["archived-workspace", archivedWorkspaceRoot],
|
||||
["archived-owner", orphanedWorkspaceRoot],
|
||||
] as const) {
|
||||
await subsystem.handleReadProjectConfigRequest({
|
||||
type: "read_project_config_request",
|
||||
requestId,
|
||||
repoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "archived-workspace",
|
||||
repoRoot: archivedWorkspaceRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "read_project_config_response",
|
||||
payload: {
|
||||
requestId: "archived-owner",
|
||||
repoRoot: orphanedWorkspaceRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("write rejects a stale revision and an unknown root with their inline domain failures", async () => {
|
||||
const staleRoot = makeRoot();
|
||||
writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
|
||||
import { resolve, sep } from "path";
|
||||
import type pino from "pino";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
import type { ProjectRegistry } from "../../workspace-registry.js";
|
||||
import type { ProjectRegistry, WorkspaceRegistry } from "../../workspace-registry.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
writePaseoConfigForEdit,
|
||||
@@ -16,24 +16,27 @@ export interface ProjectConfigSessionHost {
|
||||
export interface ProjectConfigSessionOptions {
|
||||
host: ProjectConfigSessionHost;
|
||||
projectRegistry: Pick<ProjectRegistry, "list">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client's read/write surface for a project's on-disk paseo.json. Resolves the
|
||||
* request's repoRoot against the known (non-archived) project roots — accepting a
|
||||
* trailing slash or a symlink via realpath — then reads or writes the config
|
||||
* substrate and emits the matching response. Reaches no state beyond the injected
|
||||
* project registry and the outbound channel.
|
||||
* request's repoRoot against known active project and workspace roots — accepting
|
||||
* a trailing slash or a symlink via realpath — then reads or writes the config
|
||||
* substrate and emits the matching response. Workspace roots remain authorized
|
||||
* only while both the workspace and its owning project are active.
|
||||
*/
|
||||
export class ProjectConfigSession {
|
||||
private readonly host: ProjectConfigSessionHost;
|
||||
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
|
||||
private readonly workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||
private readonly logger: pino.Logger;
|
||||
|
||||
constructor(options: ProjectConfigSessionOptions) {
|
||||
this.host = options.host;
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
@@ -153,15 +156,27 @@ export class ProjectConfigSession {
|
||||
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
|
||||
const requestedRoot = canonicalizeConfigRoot(repoRoot);
|
||||
const projects = await this.projectRegistry.list();
|
||||
const activeProjectIds = new Set<string>();
|
||||
for (const project of projects) {
|
||||
if (project.archivedAt !== null) {
|
||||
continue;
|
||||
}
|
||||
activeProjectIds.add(project.projectId);
|
||||
const projectRoot = canonicalizeConfigRoot(project.rootPath);
|
||||
if (requestedRoot === projectRoot) {
|
||||
return projectRoot;
|
||||
}
|
||||
}
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.archivedAt !== null || !activeProjectIds.has(workspace.projectId)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceRoot = canonicalizeConfigRoot(workspace.cwd);
|
||||
if (requestedRoot === workspaceRoot) {
|
||||
return workspaceRoot;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import {
|
||||
createPaseoDaemon,
|
||||
type ListenTarget,
|
||||
type PaseoDaemonConfig,
|
||||
type PaseoOpenAIConfig,
|
||||
type PaseoSpeechConfig,
|
||||
@@ -19,6 +20,7 @@ interface TestPaseoDaemonOptions {
|
||||
downloadTokenTtlMs?: number;
|
||||
corsAllowedOrigins?: string[];
|
||||
listen?: string;
|
||||
allowIpc?: boolean;
|
||||
logger?: Parameters<typeof createPaseoDaemon>[1];
|
||||
mcpEnabled?: boolean;
|
||||
mcpDebug?: boolean;
|
||||
@@ -49,6 +51,7 @@ export interface TestPaseoDaemon {
|
||||
config: PaseoDaemonConfig;
|
||||
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>;
|
||||
port: number;
|
||||
listenTarget: ListenTarget;
|
||||
paseoHome: string;
|
||||
staticDir: string;
|
||||
close: () => Promise<void>;
|
||||
@@ -96,8 +99,8 @@ export async function createTestPaseoDaemon(
|
||||
try {
|
||||
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
||||
throw new Error("Test daemon did not expose a bound TCP listen target");
|
||||
if (!listenTarget || (listenTarget.type !== "tcp" && !options.allowIpc)) {
|
||||
throw new Error("Test daemon did not expose an allowed listen target");
|
||||
}
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
@@ -115,7 +118,8 @@ export async function createTestPaseoDaemon(
|
||||
return {
|
||||
config,
|
||||
daemon,
|
||||
port: listenTarget.port,
|
||||
port: listenTarget.type === "tcp" ? listenTarget.port : 0,
|
||||
listenTarget,
|
||||
paseoHome,
|
||||
staticDir,
|
||||
close,
|
||||
@@ -157,7 +161,7 @@ async function prepareTestDaemonConfig(
|
||||
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
|
||||
const listenHost = options.listen ?? "127.0.0.1";
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:0`,
|
||||
listen: options.allowIpc ? listenHost : `${listenHost}:0`,
|
||||
paseoHome,
|
||||
daemonVersion: options.daemonVersion,
|
||||
desktopManaged: options.desktopManaged,
|
||||
|
||||
@@ -1376,6 +1376,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
projectAdd: true,
|
||||
// COMPAT(hostAutomation): added in v0.1.111, remove after 2027-01-18 once daemon floor >= v0.1.111.
|
||||
hostAutomation: true,
|
||||
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
|
||||
worktreeRestore: true,
|
||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||
|
||||
@@ -117,6 +117,7 @@ export async function createWorktreeCore(
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
if (existingWorktree) {
|
||||
assertExistingWorktreeMatchesIntent(existingWorktree, intent, normalizedSlug);
|
||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
||||
}
|
||||
|
||||
@@ -135,6 +136,22 @@ export async function createWorktreeCore(
|
||||
};
|
||||
}
|
||||
|
||||
function assertExistingWorktreeMatchesIntent(
|
||||
existingWorktree: WorktreeConfig,
|
||||
intent: WorktreeCreationIntent,
|
||||
slug: string,
|
||||
): void {
|
||||
const intendedBranchName =
|
||||
intent.kind === "checkout-change-request" || intent.kind === "checkout-github-pr"
|
||||
? (intent.localBranchName ?? intent.headRef)
|
||||
: intent.branchName;
|
||||
if (existingWorktree.branchName !== intendedBranchName) {
|
||||
throw new Error(
|
||||
`Worktree name ${slug} is already used by branch ${existingWorktree.branchName}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveForge(
|
||||
repoRoot: string,
|
||||
deps: CreateWorktreeCoreDeps,
|
||||
|
||||
Reference in New Issue
Block a user