mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge remote-tracking branch 'origin/main' into dev
# Conflicts: # packages/server/src/server/workspace-registry-model.test.ts # packages/server/src/utils/worktree.ts
This commit is contained in:
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@@ -102,7 +102,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Windows-critical server tests
|
- name: Run Windows-critical server tests
|
||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
run: >
|
||||||
|
npx vitest run
|
||||||
|
src/utils/executable.test.ts
|
||||||
|
src/utils/spawn.test.ts
|
||||||
|
src/utils/run-git-command.test.ts
|
||||||
|
src/utils/checkout-git-rev-parse.test.ts
|
||||||
|
src/server/agent/provider-registry.test.ts
|
||||||
|
src/server/agent/provider-launch-config.test.ts
|
||||||
|
src/server/agent/provider-snapshot-manager.test.ts
|
||||||
|
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||||
|
src/server/agent/providers/provider-availability.test.ts
|
||||||
|
src/server/workspace-registry-model.test.ts
|
||||||
|
src/server/persisted-config.test.ts
|
||||||
|
src/server/bootstrap-provider-availability.test.ts
|
||||||
|
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-UrTPJVju3jiSlkUMfh25rWobIo9hWNVFoVewFGxRQC0=";
|
npmDepsHash = "sha256-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -35365,6 +35365,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.25.1"
|
"zod-to-json-schema": "^3.25.1"
|
||||||
@@ -35588,6 +35589,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/isexe": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/media-typer": {
|
"packages/server/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
@@ -35742,6 +35752,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/which": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/which.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/yocto-queue": {
|
"packages/server/node_modules/yocto-queue": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.25.1"
|
"zod-to-json-schema": "^3.25.1"
|
||||||
|
|||||||
@@ -659,6 +659,12 @@ export class AgentManager {
|
|||||||
: overrides;
|
: overrides;
|
||||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||||
const client = this.requireClient(handle.provider);
|
const client = this.requireClient(handle.provider);
|
||||||
|
const available = await client.isAvailable();
|
||||||
|
if (!available) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
|
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
|
||||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
type Options,
|
||||||
|
type Query,
|
||||||
|
type SpawnOptions as ClaudeSpawnOptions,
|
||||||
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
import * as spawnUtils from "../../../utils/spawn.js";
|
||||||
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
|
|
||||||
|
function createQueryMock(events: unknown[]): Query {
|
||||||
|
let index = 0;
|
||||||
|
return {
|
||||||
|
next: vi.fn(async () =>
|
||||||
|
index < events.length
|
||||||
|
? { done: false, value: events[index++] }
|
||||||
|
: { done: true, value: undefined },
|
||||||
|
),
|
||||||
|
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||||
|
interrupt: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
|
setModel: vi.fn(async () => undefined),
|
||||||
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as Query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChildProcessStub(): ChildProcess {
|
||||||
|
return {
|
||||||
|
stderr: new EventEmitter(),
|
||||||
|
} as ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Claude spawn override", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bypasses the shell when spawning Claude Code", async () => {
|
||||||
|
let capturedOptions: Options | undefined;
|
||||||
|
const queryFactory = vi.fn(({ options }: Parameters<typeof query>[0]) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
return createQueryMock([
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "claude-spawn-shell-regression-session",
|
||||||
|
permissionMode: "default",
|
||||||
|
model: "opus",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: "done" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
output_tokens: 1,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
const spawnSpy = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
queryFactory,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.run("spawn shell regression");
|
||||||
|
capturedOptions?.spawnClaudeCodeProcess?.({
|
||||||
|
command: "node",
|
||||||
|
args: ["claude.js", "--mcp-config", '{"mcpServers":{"paseo":{"type":"http"}}}'],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {},
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
} satisfies ClaudeSpawnOptions);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const spawnOptions = spawnSpy.mock.calls[0]?.[2];
|
||||||
|
expect(spawnOptions?.shell).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1135,6 +1135,8 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
return await isCommandAvailable(command.argv[0]);
|
return await isCommandAvailable(command.argv[0]);
|
||||||
}
|
}
|
||||||
|
// Default mode uses @anthropic-ai/claude-agent-sdk's bundled cli.js run
|
||||||
|
// via process.execPath. No external `claude` binary is required.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4215,7 +4215,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
return await isCommandAvailable(command.argv[0]);
|
return await isCommandAvailable(command.argv[0]);
|
||||||
}
|
}
|
||||||
return true;
|
return await isCommandAvailable("codex");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||||
|
|||||||
@@ -1041,7 +1041,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
return await isCommandAvailable(command.argv[0]);
|
return await isCommandAvailable(command.argv[0]);
|
||||||
}
|
}
|
||||||
return true;
|
return await isCommandAvailable("opencode");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
import type { AgentProvider } from "../agent-sdk-types.js";
|
||||||
|
import { AgentManager } from "../agent-manager.js";
|
||||||
|
import { AgentStorage } from "../agent-storage.js";
|
||||||
|
|
||||||
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
|
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||||
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
|
const originalEnv = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
PATHEXT: process.env.PATHEXT,
|
||||||
|
};
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function makeTempDir(prefix: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isolatePathTo(dir: string): void {
|
||||||
|
process.env.PATH = dir;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
process.env.PATHEXT = ".CMD";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeProviderShim(dir: string, command: string): string {
|
||||||
|
const filePath = process.platform === "win32" ? join(dir, `${command}.cmd`) : join(dir, command);
|
||||||
|
const content =
|
||||||
|
process.platform === "win32"
|
||||||
|
? `@echo off\r\necho ${command} 1.0\r\n`
|
||||||
|
: `#!/bin/sh\necho ${command} 1.0\n`;
|
||||||
|
writeFileSync(filePath, content);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.PATH = originalEnv.PATH;
|
||||||
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("default provider availability", () => {
|
||||||
|
test("Codex reports unavailable when the default command cannot be resolved", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-codex-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Claude reports available without a PATH binary because the SDK bundles its own cli.js", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-claude-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenCode reports unavailable when the default command cannot be resolved", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-opencode-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Codex reports available when the default command resolves from PATH", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-codex-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
writeProviderShim(binDir, "codex");
|
||||||
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenCode reports available when the default command resolves from PATH", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-opencode-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
writeProviderShim(binDir, "opencode");
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
const workdir = makeTempDir("provider-availability-manager-work-");
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new CodexAppServerAgentClient(createTestLogger()),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.listProviderAvailability()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
available: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
|
||||||
|
const binDir = makeTempDir("provider-availability-resume-bin-");
|
||||||
|
isolatePathTo(binDir);
|
||||||
|
const workdir = makeTempDir("provider-availability-resume-work-");
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new CodexAppServerAgentClient(createTestLogger()),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger: createTestLogger(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.resumeAgentFromPersistence(
|
||||||
|
{
|
||||||
|
provider: "codex" as AgentProvider,
|
||||||
|
sessionId: "missing-codex-session",
|
||||||
|
metadata: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ cwd: workdir },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Provider 'codex' is not available");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import pino from "pino";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { ensureAgentLoaded } from "./agent/agent-loading.js";
|
||||||
|
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
|
|
||||||
|
const originalEnv = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
PATHEXT: process.env.PATHEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("bootstrap provider availability", () => {
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
process.env.PATH = originalEnv.PATH;
|
||||||
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
|
for (const root of tempRoots.splice(0)) {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads a persisted Codex record without spawning a missing Codex binary", async () => {
|
||||||
|
const { createPaseoDaemon } = await import("./bootstrap.js");
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
|
||||||
|
tempRoots.push(root);
|
||||||
|
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
|
||||||
|
tempRoots.push(binDir);
|
||||||
|
process.env.PATH = binDir;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
process.env.PATHEXT = ".CMD";
|
||||||
|
}
|
||||||
|
const paseoHome = path.join(root, ".paseo");
|
||||||
|
const staticDir = path.join(root, "static");
|
||||||
|
const agentStoragePath = path.join(paseoHome, "agents");
|
||||||
|
const now = new Date("2026-04-16T00:00:00.000Z").toISOString();
|
||||||
|
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
await mkdir(agentStoragePath, { recursive: true });
|
||||||
|
await mkdir(staticDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentStoragePath, `${agentId}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
id: agentId,
|
||||||
|
provider: "codex",
|
||||||
|
cwd: root,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastStatus: "idle",
|
||||||
|
lastModeId: "auto",
|
||||||
|
config: {
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
},
|
||||||
|
persistence: {
|
||||||
|
provider: "codex",
|
||||||
|
sessionId: "codex-session-1",
|
||||||
|
metadata: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: root,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: PaseoDaemonConfig = {
|
||||||
|
listen: "127.0.0.1:0",
|
||||||
|
paseoHome,
|
||||||
|
corsAllowedOrigins: [],
|
||||||
|
hostnames: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
staticDir,
|
||||||
|
mcpDebug: false,
|
||||||
|
agentClients: {},
|
||||||
|
agentStoragePath,
|
||||||
|
relayEnabled: false,
|
||||||
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
openai: undefined,
|
||||||
|
speech: undefined,
|
||||||
|
};
|
||||||
|
const processFailures: Error[] = [];
|
||||||
|
const onUnhandledRejection = (reason: unknown) => {
|
||||||
|
processFailures.push(reason instanceof Error ? reason : new Error(String(reason)));
|
||||||
|
};
|
||||||
|
const onUncaughtException = (error: Error) => {
|
||||||
|
processFailures.push(error);
|
||||||
|
};
|
||||||
|
process.on("unhandledRejection", onUnhandledRejection);
|
||||||
|
process.on("uncaughtException", onUncaughtException);
|
||||||
|
|
||||||
|
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||||
|
try {
|
||||||
|
await expect(daemon.agentStorage.list()).resolves.toHaveLength(1);
|
||||||
|
await expect(daemon.agentManager.listProviderAvailability()).resolves.toContainEqual({
|
||||||
|
provider: "codex",
|
||||||
|
available: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
ensureAgentLoaded(agentId, {
|
||||||
|
agentManager: daemon.agentManager,
|
||||||
|
agentStorage: daemon.agentStorage,
|
||||||
|
logger: pino({ level: "silent" }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Provider 'codex' is not available");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(processFailures).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
process.off("unhandledRejection", onUnhandledRejection);
|
||||||
|
process.off("uncaughtException", onUncaughtException);
|
||||||
|
await daemon.stop().catch(() => undefined);
|
||||||
|
await daemon.agentManager.flush().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveGhPath,
|
resolveGhPath,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
|
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
@@ -368,15 +369,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await this.deps.runGitCommand(
|
const { stdout } = await this.deps.runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
cwd,
|
||||||
{
|
env: READ_ONLY_GIT_ENV,
|
||||||
cwd,
|
});
|
||||||
env: READ_ONLY_GIT_ENV,
|
return parseGitRevParsePath(stdout);
|
||||||
},
|
|
||||||
);
|
|
||||||
const root = stdout.trim();
|
|
||||||
return root.length > 0 ? root : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
106
packages/server/src/server/workspace-registry-model.test.ts
Normal file
106
packages/server/src/server/workspace-registry-model.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveWorkspaceId,
|
||||||
|
detectStaleWorkspaces,
|
||||||
|
normalizeWorkspaceId,
|
||||||
|
} from "./workspace-registry-model.js";
|
||||||
|
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
|
function createWorkspaceRecord(workspaceId: string) {
|
||||||
|
return createPersistedWorkspaceRecord({
|
||||||
|
workspaceId,
|
||||||
|
projectId: workspaceId,
|
||||||
|
cwd: workspaceId,
|
||||||
|
kind: "directory",
|
||||||
|
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
|
||||||
|
createdAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("detectStaleWorkspaces", () => {
|
||||||
|
test("returns workspace ids whose directories no longer exist", async () => {
|
||||||
|
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
|
||||||
|
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [
|
||||||
|
createWorkspaceRecord("/tmp/existing"),
|
||||||
|
createWorkspaceRecord("/tmp/missing"),
|
||||||
|
],
|
||||||
|
checkDirectoryExists,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]);
|
||||||
|
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
|
||||||
|
checkDirectoryExists: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps workspaces with no agents when directory exists", async () => {
|
||||||
|
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||||
|
activeWorkspaces: [
|
||||||
|
createWorkspaceRecord("/tmp/active"),
|
||||||
|
createWorkspaceRecord("/tmp/no-agents"),
|
||||||
|
],
|
||||||
|
checkDirectoryExists: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveWorkspaceId", () => {
|
||||||
|
test("uses git worktree root when available", () => {
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceId("/tmp/repo/packages/app", {
|
||||||
|
cwd: "/tmp/repo/packages/app",
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
|
worktreeRoot: "/tmp/repo",
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
).toBe("/tmp/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to normalized cwd when git worktree root contains multiple lines", () => {
|
||||||
|
const cwd = String.raw`E:\project\node-ai`;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceId(cwd, {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: `--path-format=absolute\n${cwd}`,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
).toBe(normalizeWorkspaceId(cwd));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to normalized cwd for non-git directories", () => {
|
||||||
|
const cwd = "/tmp/repo/../repo/scratch";
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceId(cwd, {
|
||||||
|
cwd,
|
||||||
|
isGit: false,
|
||||||
|
currentBranch: null,
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: null,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
}),
|
||||||
|
).toBe(normalizeWorkspaceId("/tmp/repo/scratch"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
||||||
|
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ export function normalizeWorkspaceId(cwd: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
|
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
|
||||||
return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd);
|
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
|
||||||
|
return worktreeRoot ?? normalizeWorkspaceId(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||||
|
|||||||
109
packages/server/src/utils/checkout-git-rev-parse.test.ts
Normal file
109
packages/server/src/utils/checkout-git-rev-parse.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const VALID_WINDOWS_ROOT = String.raw`E:\project\node-ai`;
|
||||||
|
const OLD_GIT_PATH_FORMAT_ECHO = `--path-format=absolute\n${VALID_WINDOWS_ROOT}\n`;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "paseo-checkout-git-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitResult(stdout: string) {
|
||||||
|
return { stdout, stderr: "", exitCode: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathForPlatform(value: string): string {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.replace(/\\/g, "/").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitCanonicalize(dir: string): string {
|
||||||
|
return execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCheckoutGitWithRevParseTopLevelOutput(stdout: string) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const runGitCommand = vi.fn(async (args: string[]) => {
|
||||||
|
if (args.join(" ") === "rev-parse --show-toplevel") {
|
||||||
|
return gitResult(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.join(" ") === "rev-parse --abbrev-ref HEAD") {
|
||||||
|
return gitResult("main\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.join(" ") === "status --porcelain") {
|
||||||
|
return gitResult("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.join(" ") === "branch --format=%(refname:short)") {
|
||||||
|
return gitResult("main\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected git command: git ${args.join(" ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("./run-git-command.js", () => ({ runGitCommand }));
|
||||||
|
|
||||||
|
const checkoutGit = await import("./checkout-git.js");
|
||||||
|
return { getCheckoutStatus: checkoutGit.getCheckoutStatus, runGitCommand };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("checkout git rev-parse path handling", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock("./run-git-command.js");
|
||||||
|
vi.resetModules();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the worktree root from a nested real git checkout", async () => {
|
||||||
|
const repoRoot = makeTempDir();
|
||||||
|
const nested = join(repoRoot, "packages", "server", "src");
|
||||||
|
mkdirSync(nested, { recursive: true });
|
||||||
|
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||||
|
|
||||||
|
const { getCheckoutStatus } = await import("./checkout-git.js");
|
||||||
|
const status = await getCheckoutStatus(nested);
|
||||||
|
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
if (!status.isGit) {
|
||||||
|
throw new Error("Expected nested checkout to be detected as a git repository");
|
||||||
|
}
|
||||||
|
expect(normalizePathForPlatform(status.repoRoot)).toBe(
|
||||||
|
normalizePathForPlatform(gitCanonicalize(repoRoot)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects multi-line rev-parse stdout and never calls the removed path-format command", async () => {
|
||||||
|
// Pre-2.31 Git is difficult to install in CI; inject its multi-line stdout
|
||||||
|
// shape at the exact production command boundary that consumes rev-parse.
|
||||||
|
const { getCheckoutStatus, runGitCommand } =
|
||||||
|
await loadCheckoutGitWithRevParseTopLevelOutput(OLD_GIT_PATH_FORMAT_ECHO);
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(VALID_WINDOWS_ROOT);
|
||||||
|
|
||||||
|
expect(status).toEqual({ isGit: false });
|
||||||
|
expect(runGitCommand).toHaveBeenCalledWith(["rev-parse", "--show-toplevel"], expect.anything());
|
||||||
|
expect(runGitCommand).not.toHaveBeenCalledWith(
|
||||||
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(runGitCommand).not.toHaveBeenCalledWith(
|
||||||
|
["rev-parse", "--git-common-dir"],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import { findExecutable } from "./executable.js";
|
import { findExecutable } from "./executable.js";
|
||||||
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { execCommand } from "./spawn.js";
|
import { execCommand } from "./spawn.js";
|
||||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||||
@@ -573,26 +574,25 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
|||||||
|
|
||||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
cwd,
|
||||||
{
|
env: READ_ONLY_GIT_ENV,
|
||||||
cwd,
|
});
|
||||||
env: READ_ONLY_GIT_ENV,
|
return parseGitRevParsePath(stdout);
|
||||||
},
|
|
||||||
);
|
|
||||||
const root = stdout.trim();
|
|
||||||
return root.length > 0 ? root : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||||
const { stdout: commonDirOut } = await runGitCommand(
|
const { stdout: commonDirOut } = await runGitCommand(["rev-parse", "--git-common-dir"], {
|
||||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
cwd,
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
env: READ_ONLY_GIT_ENV,
|
||||||
);
|
});
|
||||||
const commonDir = commonDirOut.trim();
|
const commonDir = resolveGitRevParsePath(cwd, commonDirOut);
|
||||||
|
if (!commonDir) {
|
||||||
|
throw new Error("Not in a git repository");
|
||||||
|
}
|
||||||
const normalized = realpathSync(commonDir);
|
const normalized = realpathSync(commonDir);
|
||||||
|
|
||||||
if (basename(normalized) === ".git") {
|
if (basename(normalized) === ".git") {
|
||||||
|
|||||||
@@ -1,213 +1,157 @@
|
|||||||
import { promisify } from "node:util";
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
import {
|
||||||
|
executableExists,
|
||||||
|
findExecutable,
|
||||||
|
quoteWindowsArgument,
|
||||||
|
quoteWindowsCommand,
|
||||||
|
} from "./executable.js";
|
||||||
|
|
||||||
async function loadExecutableModule(params?: {
|
const originalEnv = {
|
||||||
execFileImpl?: (
|
PATH: process.env.PATH,
|
||||||
command: string,
|
PATHEXT: process.env.PATHEXT,
|
||||||
args: string[],
|
};
|
||||||
options: unknown,
|
const tempDirs: string[] = [];
|
||||||
callback: ExecFileCallback,
|
|
||||||
) => void;
|
|
||||||
}) {
|
|
||||||
vi.resetModules();
|
|
||||||
|
|
||||||
const execFileMock = vi.fn(
|
function makeTempDir(): string {
|
||||||
params?.execFileImpl ??
|
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-executable-test-"));
|
||||||
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
tempDirs.push(dir);
|
||||||
callback(new Error("execFile not mocked"), "", "");
|
return dir;
|
||||||
}),
|
|
||||||
);
|
|
||||||
Object.assign(execFileMock, {
|
|
||||||
[promisify.custom]: (command: string, args: string[], options: unknown) =>
|
|
||||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
||||||
execFileMock(
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
options,
|
|
||||||
(error: Error | null, stdout: string, stderr: string) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve({ stdout, stderr });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.doMock("node:child_process", () => ({
|
|
||||||
execFile: execFileMock,
|
|
||||||
}));
|
|
||||||
const module = await import("./executable.js");
|
|
||||||
return {
|
|
||||||
...module,
|
|
||||||
execFileMock,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("findExecutable", () => {
|
function prependPath(...dirs: string[]): void {
|
||||||
const originalPlatform = process.platform;
|
process.env.PATH = [...dirs, originalEnv.PATH].filter(Boolean).join(path.delimiter);
|
||||||
const missingBinaryName = "nonexistent-binary-xyz-12345";
|
}
|
||||||
|
|
||||||
function setPlatform(value: string) {
|
function writeExecutable(filePath: string, content: string): string {
|
||||||
Object.defineProperty(process, "platform", { value, writable: true });
|
writeFileSync(filePath, content);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
}
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
function writeInvokableFixture(dir: string, name: string): string {
|
||||||
setPlatform(originalPlatform);
|
if (process.platform === "win32") {
|
||||||
});
|
return writeExecutable(path.join(dir, `${name}.cmd`), "@echo off\r\necho 0.1\r\n");
|
||||||
|
}
|
||||||
|
return writeExecutable(path.join(dir, name), "#!/bin/sh\necho 0.1\n");
|
||||||
|
}
|
||||||
|
|
||||||
test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
|
function writeBrokenAbsoluteFixture(dir: string): string {
|
||||||
setPlatform("win32");
|
const filePath =
|
||||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
process.platform === "win32" ? path.join(dir, "broken.exe") : path.join(dir, "broken");
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
writeFileSync(filePath, "not executable");
|
||||||
callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
|
if (process.platform !== "win32") {
|
||||||
},
|
chmodSync(filePath, 0o644);
|
||||||
});
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
await expect(findExecutable("claude")).resolves.toBe(
|
function expectWindowsPathsEqual(actual: string | null, expected: string): void {
|
||||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
|
expect(actual?.toLowerCase()).toBe(expected.toLowerCase());
|
||||||
);
|
}
|
||||||
expect(execFileMock).toHaveBeenCalledOnce();
|
|
||||||
const call = execFileMock.mock.calls[0];
|
afterEach(() => {
|
||||||
expect(call?.[0]).toBe("where.exe");
|
process.env.PATH = originalEnv.PATH;
|
||||||
expect(call?.[1]).toEqual(["claude"]);
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
expect(call?.[2]).toMatchObject({
|
for (const dir of tempDirs.splice(0)) {
|
||||||
encoding: "utf8",
|
rmSync(dir, { recursive: true, force: true });
|
||||||
windowsHide: true,
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findExecutable", () => {
|
||||||
|
describe.skipIf(process.platform === "win32")("POSIX", () => {
|
||||||
|
test("finds an extensionless executable and skips an earlier non-executable candidate", async () => {
|
||||||
|
const executableDir = makeTempDir();
|
||||||
|
const nonExecutableDir = makeTempDir();
|
||||||
|
const executable = writeExecutable(path.join(executableDir, "foo"), "#!/bin/sh\necho 0.1\n");
|
||||||
|
const nonExecutable = path.join(nonExecutableDir, "foo");
|
||||||
|
writeFileSync(nonExecutable, "#!/bin/sh\necho broken\n");
|
||||||
|
chmodSync(nonExecutable, 0o644);
|
||||||
|
prependPath(nonExecutableDir, executableDir);
|
||||||
|
|
||||||
|
await expect(findExecutable("foo")).resolves.toBe(executable);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("on Windows, prefers an executable match from where.exe output", async () => {
|
describe.runIf(process.platform === "win32")("Windows", () => {
|
||||||
setPlatform("win32");
|
test("returns a working .cmd when an invalid .exe candidate appears first", async () => {
|
||||||
const { findExecutable } = await loadExecutableModule({
|
const dir = makeTempDir();
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
process.env.PATHEXT = [".EXE", ".CMD"].join(path.delimiter);
|
||||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
|
const brokenExe = path.join(dir, "foo.exe");
|
||||||
},
|
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
|
||||||
|
writeFileSync(brokenExe, "");
|
||||||
|
prependPath(dir);
|
||||||
|
|
||||||
|
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
|
test("returns null when the only candidate is a broken .exe", async () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
process.env.PATHEXT = ".EXE";
|
||||||
|
writeFileSync(path.join(dir, "foo.exe"), "");
|
||||||
|
prependPath(dir);
|
||||||
|
|
||||||
|
await expect(findExecutable("foo")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a .cmd when it is the only candidate", async () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
process.env.PATHEXT = ".CMD";
|
||||||
|
const cmd = writeExecutable(path.join(dir, "foo.cmd"), "@echo off\r\necho 0.1\r\n");
|
||||||
|
prependPath(dir);
|
||||||
|
|
||||||
|
expectWindowsPathsEqual(await findExecutable("foo"), cmd);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
|
test("returns an invokable absolute path", async () => {
|
||||||
setPlatform("win32");
|
const dir = makeTempDir();
|
||||||
const { findExecutable } = await loadExecutableModule({
|
const fixture = writeInvokableFixture(dir, "absolute-ok");
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(
|
|
||||||
null,
|
|
||||||
[
|
|
||||||
"C:\\nvm4w\\nodejs\\codex",
|
|
||||||
"C:\\nvm4w\\nodejs\\codex.ps1",
|
|
||||||
"C:\\nvm4w\\nodejs\\codex.cmd",
|
|
||||||
"C:\\nvm4w\\nodejs\\codex.exe",
|
|
||||||
].join("\r\n"),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
|
await expect(findExecutable(fixture)).resolves.toBe(fixture);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("on Windows, returns null when where.exe output is empty", async () => {
|
test("returns null for an absolute path that cannot spawn", async () => {
|
||||||
setPlatform("win32");
|
const dir = makeTempDir();
|
||||||
const { findExecutable } = await loadExecutableModule({
|
const fixture = writeBrokenAbsoluteFixture(dir);
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(null, "\r\n", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
|
await expect(findExecutable(fixture)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
|
test("returns null when the command is not on PATH", async () => {
|
||||||
setPlatform("win32");
|
const dir = makeTempDir();
|
||||||
const { findExecutable } = await loadExecutableModule({
|
prependPath(dir);
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
|
await expect(findExecutable("paseo-definitely-missing-command")).resolves.toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
test("on Unix, uses the last line from which output", async () => {
|
|
||||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(null, "/usr/local/bin/codex\n", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
|
||||||
process.platform === "win32" ? "where.exe" : "which",
|
|
||||||
["codex"],
|
|
||||||
process.platform === "win32" ? { encoding: "utf8", windowsHide: true } : { encoding: "utf8" },
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(process.platform === "win32")(
|
|
||||||
"warns and returns null when the final which line is not an absolute path",
|
|
||||||
async () => {
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const { findExecutable } = await loadExecutableModule({
|
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(null, "codex\n", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
|
||||||
expect(warnSpy).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("returns null when which lookup fails", async () => {
|
|
||||||
const { findExecutable } = await loadExecutableModule({
|
|
||||||
execFileImpl: (_command, _args, _options, callback) => {
|
|
||||||
callback(new Error("which failed"), "", "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("executableExists", () => {
|
describe("executableExists", () => {
|
||||||
const originalPlatform = process.platform;
|
test("returns the path when it already exists", () => {
|
||||||
|
const exists = (candidate: string) => candidate === "/usr/local/bin/codex";
|
||||||
function setPlatform(value: string) {
|
|
||||||
Object.defineProperty(process, "platform", { value, writable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
setPlatform(originalPlatform);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns the path when it already exists", async () => {
|
|
||||||
const { executableExists } = await loadExecutableModule();
|
|
||||||
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
|
|
||||||
|
|
||||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => {
|
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", () => {
|
||||||
setPlatform("win32");
|
const originalPlatform = process.platform;
|
||||||
const { executableExists } = await loadExecutableModule();
|
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||||
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
|
try {
|
||||||
|
const exists = (candidate: string) => candidate === "C:\\tools\\codex.cmd";
|
||||||
|
|
||||||
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when no matching path exists", async () => {
|
test("returns null when no matching path exists", () => {
|
||||||
const { executableExists } = await loadExecutableModule();
|
expect(executableExists("/missing/codex", () => false)).toBeNull();
|
||||||
const exists = vi.fn(() => false);
|
|
||||||
|
|
||||||
expect(executableExists("/missing/codex", exists)).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,71 +166,61 @@ describe("quoteWindowsCommand", () => {
|
|||||||
setPlatform(originalPlatform);
|
setPlatform(originalPlatform);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quotes a Windows path with spaces", async () => {
|
test("quotes a Windows path with spaces", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not double-quote an already-quoted path", async () => {
|
test("does not double-quote an already-quoted path", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the command unchanged when there are no spaces", async () => {
|
test("returns the command unchanged when there are no spaces", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes ampersands", async () => {
|
test("escapes ampersands", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes pipes", async () => {
|
test("escapes pipes", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doubles percent signs", async () => {
|
test("doubles percent signs", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("100%")).toBe("100%%");
|
expect(quoteWindowsCommand("100%")).toBe("100%%");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes carets", async () => {
|
test("escapes carets", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes multiple metacharacters", async () => {
|
test("escapes multiple metacharacters", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
||||||
"build^&^(test^|deploy^)^!^<output^>",
|
"build^&^(test^|deploy^)^!^<output^>",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quotes commands with spaces after escaping metacharacters", async () => {
|
test("quotes commands with spaces after escaping metacharacters", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
||||||
'"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"',
|
'"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the command unchanged on non-Windows platforms", async () => {
|
test("returns the command unchanged on non-Windows platforms", () => {
|
||||||
setPlatform("darwin");
|
setPlatform("darwin");
|
||||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,31 +236,27 @@ describe("quoteWindowsArgument", () => {
|
|||||||
setPlatform(originalPlatform);
|
setPlatform(originalPlatform);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("quotes a Windows argument with spaces", async () => {
|
test("quotes a Windows argument with spaces", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not double-quote an already-quoted argument", async () => {
|
test("does not double-quote an already-quoted argument", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the argument unchanged when there are no spaces", async () => {
|
test("returns the argument unchanged when there are no spaces", () => {
|
||||||
setPlatform("win32");
|
setPlatform("win32");
|
||||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the argument unchanged on non-Windows platforms", async () => {
|
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||||
setPlatform("darwin");
|
setPlatform("darwin");
|
||||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
|
||||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +1,100 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path, { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
type Which = (command: string, options: { all: true }) => Promise<string[]>;
|
||||||
|
|
||||||
function pickBestWindowsCandidate(lines: string[]): string | null {
|
const require = createRequire(import.meta.url);
|
||||||
const candidates = lines.filter((line) => line.length > 0);
|
const which = require("which") as Which;
|
||||||
if (candidates.length === 0) return null;
|
const PROBE_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
const extPriority = [".exe", ".cmd", ".ps1"];
|
function hasPathSeparator(value: string): boolean {
|
||||||
for (const ext of extPriority) {
|
return value.includes("/") || value.includes("\\");
|
||||||
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
|
|
||||||
if (match) return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExecutableFromWhichOutput(
|
async function enumerateCandidates(name: string): Promise<string[]> {
|
||||||
name: string,
|
let candidates: string[];
|
||||||
output: string,
|
try {
|
||||||
source: "which",
|
candidates = await which(name, { all: true });
|
||||||
): string | null {
|
} catch (error) {
|
||||||
const lines = output
|
// `which` throws ENOENT when the command is absent from PATH.
|
||||||
.split(/\r?\n/)
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
.map((line) => line.trim())
|
return [];
|
||||||
.filter((line) => line.length > 0);
|
}
|
||||||
const candidate = lines.at(-1);
|
throw error;
|
||||||
|
|
||||||
if (!candidate) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!path.isAbsolute(candidate)) {
|
const seen = new Set<string>();
|
||||||
console.warn(
|
return candidates.filter((candidate) => {
|
||||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
if (seen.has(candidate)) {
|
||||||
);
|
return false;
|
||||||
return null;
|
}
|
||||||
}
|
seen.add(candidate);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return candidate;
|
function isWindowsCommandScript(executablePath: string): boolean {
|
||||||
|
const extension = extname(executablePath).toLowerCase();
|
||||||
|
return process.platform === "win32" && (extension === ".cmd" || extension === ".bat");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeExecutable(executablePath: string): Promise<boolean> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let started = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const settle = (result: boolean) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = spawn(executablePath, ["--version"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
// Windows batch shims (.cmd/.bat) require cmd.exe; native binaries do not.
|
||||||
|
shell: isWindowsCommandScript(executablePath),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
settle(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (started) {
|
||||||
|
child.kill();
|
||||||
|
settle(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle(false);
|
||||||
|
}, PROBE_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
child.once("spawn", () => {
|
||||||
|
started = true;
|
||||||
|
});
|
||||||
|
child.once("error", () => {
|
||||||
|
// ENOENT/EACCES/EPERM/UNKNOWN here means the OS could not start the candidate.
|
||||||
|
settle(started);
|
||||||
|
});
|
||||||
|
child.once("exit", () => {
|
||||||
|
settle(started);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On Unix we use `which`. On Windows we use `where.exe`.
|
* Check a literal executable path. PATH search is handled by findExecutable().
|
||||||
*
|
|
||||||
* Both rely on the inherited process.env.PATH — on macOS/Linux, Electron
|
|
||||||
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
|
|
||||||
* inherits the full user environment from Explorer.
|
|
||||||
*/
|
*/
|
||||||
export function executableExists(
|
export function executableExists(
|
||||||
executablePath: string,
|
executablePath: string,
|
||||||
@@ -70,35 +116,18 @@ export async function findExecutable(name: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
if (hasPathSeparator(trimmed)) {
|
||||||
return executableExists(trimmed);
|
return (await probeExecutable(trimmed)) ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
const candidates = await enumerateCandidates(trimmed);
|
||||||
try {
|
for (const candidate of candidates) {
|
||||||
const { stdout } = await execFileAsync("where.exe", [trimmed], {
|
if (await probeExecutable(candidate)) {
|
||||||
encoding: "utf8",
|
return candidate;
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
pickBestWindowsCandidate(
|
|
||||||
stdout
|
|
||||||
.trim()
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim()),
|
|
||||||
) ?? null
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return null;
|
||||||
const { stdout } = await execFileAsync("which", [trimmed], { encoding: "utf8" });
|
|
||||||
return resolveExecutableFromWhichOutput(trimmed, stdout.trim(), "which");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isCommandAvailable(command: string): Promise<boolean> {
|
export async function isCommandAvailable(command: string): Promise<boolean> {
|
||||||
|
|||||||
25
packages/server/src/utils/git-rev-parse-path.ts
Normal file
25
packages/server/src/utils/git-rev-parse-path.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
export function parseGitRevParsePath(stdout: string): string | null {
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = trimmed.split(/\r?\n/);
|
||||||
|
if (lines.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = lines[0]?.trim() ?? "";
|
||||||
|
if (!path || path.startsWith("--")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGitRevParsePath(cwd: string, stdout: string): string | null {
|
||||||
|
const parsed = parseGitRevParsePath(stdout);
|
||||||
|
return parsed ? resolve(cwd, parsed) : null;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
||||||
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
|
|
||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
worktree?: {
|
worktree?: {
|
||||||
@@ -529,14 +530,11 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
|||||||
} catch {
|
} catch {
|
||||||
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
|
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
cwd: worktreePath,
|
||||||
{
|
env: READ_ONLY_GIT_ENV,
|
||||||
cwd: worktreePath,
|
});
|
||||||
env: READ_ONLY_GIT_ENV,
|
const topLevel = parseGitRevParsePath(stdout);
|
||||||
},
|
|
||||||
);
|
|
||||||
const topLevel = stdout.trim();
|
|
||||||
if (topLevel) {
|
if (topLevel) {
|
||||||
return normalizePathForOwnership(topLevel);
|
return normalizePathForOwnership(topLevel);
|
||||||
}
|
}
|
||||||
@@ -716,14 +714,11 @@ export async function runWorktreeTeardownCommands(options: {
|
|||||||
* This is where refs, objects, etc. are stored.
|
* This is where refs, objects, etc. are stored.
|
||||||
*/
|
*/
|
||||||
export async function getGitCommonDir(cwd: string): Promise<string> {
|
export async function getGitCommonDir(cwd: string): Promise<string> {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], {
|
||||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
cwd,
|
||||||
{
|
env: READ_ONLY_GIT_ENV,
|
||||||
cwd,
|
});
|
||||||
env: READ_ONLY_GIT_ENV,
|
const commonDir = resolveGitRevParsePath(cwd, stdout);
|
||||||
},
|
|
||||||
);
|
|
||||||
const commonDir = stdout.trim();
|
|
||||||
if (!commonDir) {
|
if (!commonDir) {
|
||||||
throw new Error("Not in a git repository");
|
throw new Error("Not in a git repository");
|
||||||
}
|
}
|
||||||
@@ -983,15 +978,11 @@ export async function resolvePaseoWorktreeRootForCwd(
|
|||||||
|
|
||||||
let worktreeRoot: string | null = null;
|
let worktreeRoot: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await runGitCommand(
|
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
cwd,
|
||||||
{
|
env: READ_ONLY_GIT_ENV,
|
||||||
cwd,
|
});
|
||||||
env: READ_ONLY_GIT_ENV,
|
worktreeRoot = parseGitRevParsePath(stdout);
|
||||||
},
|
|
||||||
);
|
|
||||||
const trimmed = stdout.trim();
|
|
||||||
worktreeRoot = trimmed.length > 0 ? trimmed : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
worktreeRoot = null;
|
worktreeRoot = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user