summaryrefslogtreecommitdiffhomepage
path: root/packages/exec-backend/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/exec-backend/src')
-rw-r--r--packages/exec-backend/src/backend.test.ts98
-rw-r--r--packages/exec-backend/src/backend.ts50
-rw-r--r--packages/exec-backend/src/extension.test.ts166
-rw-r--r--packages/exec-backend/src/extension.ts66
-rw-r--r--packages/exec-backend/src/index.ts4
-rw-r--r--packages/exec-backend/src/local.test.ts372
-rw-r--r--packages/exec-backend/src/local.ts216
-rw-r--r--packages/exec-backend/src/service.ts2
8 files changed, 487 insertions, 487 deletions
diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts
index 30458e7..2e454ea 100644
--- a/packages/exec-backend/src/backend.test.ts
+++ b/packages/exec-backend/src/backend.test.ts
@@ -6,58 +6,58 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from
* (Pure compile-time + runtime check; zero internal mocks.)
*/
describe("ExecBackend type conformance", () => {
- it("a minimal fake satisfies the ExecBackend interface", () => {
- const fake: ExecBackend = {
- spawn: async (_params: SpawnParams): Promise<ExecResult> => ({
- exitCode: 0,
- timedOut: false,
- aborted: false,
- }),
- readFile: async (_path: string): Promise<string> => "",
- writeFile: async (_path: string, _content: string): Promise<void> => {},
- stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }),
- readdir: async (_path: string): Promise<readonly DirEntry[]> => [],
- exists: async (_path: string): Promise<boolean> => true,
- };
+ it("a minimal fake satisfies the ExecBackend interface", () => {
+ const fake: ExecBackend = {
+ spawn: async (_params: SpawnParams): Promise<ExecResult> => ({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ }),
+ readFile: async (_path: string): Promise<string> => "",
+ writeFile: async (_path: string, _content: string): Promise<void> => {},
+ stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }),
+ readdir: async (_path: string): Promise<readonly DirEntry[]> => [],
+ exists: async (_path: string): Promise<boolean> => true,
+ };
- // Runtime sanity: every method is present and callable.
- expect(typeof fake.spawn).toBe("function");
- expect(typeof fake.readFile).toBe("function");
- expect(typeof fake.writeFile).toBe("function");
- expect(typeof fake.stat).toBe("function");
- expect(typeof fake.readdir).toBe("function");
- expect(typeof fake.exists).toBe("function");
- });
+ // Runtime sanity: every method is present and callable.
+ expect(typeof fake.spawn).toBe("function");
+ expect(typeof fake.readFile).toBe("function");
+ expect(typeof fake.writeFile).toBe("function");
+ expect(typeof fake.stat).toBe("function");
+ expect(typeof fake.readdir).toBe("function");
+ expect(typeof fake.exists).toBe("function");
+ });
- it("ExecResult is { exitCode, timedOut, aborted }", () => {
- const result: ExecResult = { exitCode: null, timedOut: true, aborted: false };
- expect(result.exitCode).toBeNull();
- expect(result.timedOut).toBe(true);
- expect(result.aborted).toBe(false);
- });
+ it("ExecResult is { exitCode, timedOut, aborted }", () => {
+ const result: ExecResult = { exitCode: null, timedOut: true, aborted: false };
+ expect(result.exitCode).toBeNull();
+ expect(result.timedOut).toBe(true);
+ expect(result.aborted).toBe(false);
+ });
- it("SpawnParams carries the shell-tool seam fields", () => {
- const params: SpawnParams = {
- command: "echo",
- cwd: "/tmp",
- signal: new AbortController().signal,
- timeout: 1000,
- onOutput: () => {},
- };
- expect(params.command).toBe("echo");
- expect(params.timeout).toBe(1000);
- });
+ it("SpawnParams carries the shell-tool seam fields", () => {
+ const params: SpawnParams = {
+ command: "echo",
+ cwd: "/tmp",
+ signal: new AbortController().signal,
+ timeout: 1000,
+ onOutput: () => {},
+ };
+ expect(params.command).toBe("echo");
+ expect(params.timeout).toBe(1000);
+ });
- it("StatResult distinguishes file vs directory", () => {
- const fileStat: StatResult = { isFile: true, isDirectory: false };
- const dirStat: StatResult = { isFile: false, isDirectory: true };
- expect(fileStat.isFile && !fileStat.isDirectory).toBe(true);
- expect(!dirStat.isFile && dirStat.isDirectory).toBe(true);
- });
+ it("StatResult distinguishes file vs directory", () => {
+ const fileStat: StatResult = { isFile: true, isDirectory: false };
+ const dirStat: StatResult = { isFile: false, isDirectory: true };
+ expect(fileStat.isFile && !fileStat.isDirectory).toBe(true);
+ expect(!dirStat.isFile && dirStat.isDirectory).toBe(true);
+ });
- it("DirEntry carries name + isDirectory", () => {
- const entry: DirEntry = { name: "sub", isDirectory: true };
- expect(entry.name).toBe("sub");
- expect(entry.isDirectory).toBe(true);
- });
+ it("DirEntry carries name + isDirectory", () => {
+ const entry: DirEntry = { name: "sub", isDirectory: true };
+ expect(entry.name).toBe("sub");
+ expect(entry.isDirectory).toBe(true);
+ });
});
diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts
index f6a807f..3eea7c0 100644
--- a/packages/exec-backend/src/backend.ts
+++ b/packages/exec-backend/src/backend.ts
@@ -24,30 +24,30 @@
/** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */
export interface ExecResult {
- readonly exitCode: number | null;
- readonly timedOut: boolean;
- readonly aborted: boolean;
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+ readonly aborted: boolean;
}
/** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */
export interface SpawnParams {
- readonly command: string;
- readonly cwd: string;
- readonly signal: AbortSignal;
- readonly timeout: number;
- readonly onOutput: (data: string, stream: "stdout" | "stderr") => void;
+ readonly command: string;
+ readonly cwd: string;
+ readonly signal: AbortSignal;
+ readonly timeout: number;
+ readonly onOutput: (data: string, stream: "stdout" | "stderr") => void;
}
/** Stat result — the subset read_file / write_file / edit_file need. */
export interface StatResult {
- readonly isFile: boolean;
- readonly isDirectory: boolean;
+ readonly isFile: boolean;
+ readonly isDirectory: boolean;
}
/** A directory entry — the subset read_file lists. */
export interface DirEntry {
- readonly name: string;
- readonly isDirectory: boolean;
+ readonly name: string;
+ readonly isDirectory: boolean;
}
/**
@@ -56,23 +56,23 @@ export interface DirEntry {
* `ToolExecuteContext.computerId` via the injected resolver.
*/
export interface ExecBackend {
- /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */
- readonly spawn: (params: SpawnParams) => Promise<ExecResult>;
+ /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */
+ readonly spawn: (params: SpawnParams) => Promise<ExecResult>;
- // --- filesystem (the read_file / write_file / edit_file surface) ---
+ // --- filesystem (the read_file / write_file / edit_file surface) ---
- /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */
- readonly readFile: (path: string) => Promise<string>;
+ /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */
+ readonly readFile: (path: string) => Promise<string>;
- /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */
- readonly writeFile: (path: string, content: string) => Promise<void>;
+ /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */
+ readonly writeFile: (path: string, content: string) => Promise<void>;
- /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */
- readonly stat: (path: string) => Promise<StatResult>;
+ /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */
+ readonly stat: (path: string) => Promise<StatResult>;
- /** List directory entries. Throws node:fs-style errors with `.code`. */
- readonly readdir: (path: string) => Promise<readonly DirEntry[]>;
+ /** List directory entries. Throws node:fs-style errors with `.code`. */
+ readonly readdir: (path: string) => Promise<readonly DirEntry[]>;
- /** Check existence without throwing (returns `false` when the path is missing). */
- readonly exists: (path: string) => Promise<boolean>;
+ /** Check existence without throwing (returns `false` when the path is missing). */
+ readonly exists: (path: string) => Promise<boolean>;
}
diff --git a/packages/exec-backend/src/extension.test.ts b/packages/exec-backend/src/extension.test.ts
index 57161a5..f32901b 100644
--- a/packages/exec-backend/src/extension.test.ts
+++ b/packages/exec-backend/src/extension.test.ts
@@ -28,94 +28,94 @@ import { execBackendHandle, remoteExecBackendFactoryHandle } from "./service.js"
* against behavior-equivalent input.
*/
function createFakeHost(services: Map<string, unknown>): HostAPI {
- const api = {
- provideService<T>(handle: ServiceHandle<T>, impl: T): void {
- services.set(handle.id, impl);
- },
- getService<T>(handle: ServiceHandle<T>): T {
- const impl = services.get(handle.id);
- if (impl === undefined) {
- throw new Error(
- `Service "${handle.id}" has no provider. Call provideService before getService.`,
- );
- }
- return impl as T;
- },
- };
- // The resolver only calls getService; the rest of HostAPI is unused here.
- return api as unknown as HostAPI;
+ const api = {
+ provideService<T>(handle: ServiceHandle<T>, impl: T): void {
+ services.set(handle.id, impl);
+ },
+ getService<T>(handle: ServiceHandle<T>): T {
+ const impl = services.get(handle.id);
+ if (impl === undefined) {
+ throw new Error(
+ `Service "${handle.id}" has no provider. Call provideService before getService.`,
+ );
+ }
+ return impl as T;
+ },
+ };
+ // The resolver only calls getService; the rest of HostAPI is unused here.
+ return api as unknown as HostAPI;
}
/** A fake remote backend — identifiable so we can assert it's the one returned. */
function createFakeRemoteBackend(marker: string): ExecBackend {
- const fail = (): never => {
- throw new Error(`fake remote backend (${marker}) should not be called in this test`);
- };
- return {
- spawn: fail,
- readFile: fail,
- writeFile: fail,
- stat: fail,
- readdir: fail,
- exists: fail,
- };
+ const fail = (): never => {
+ throw new Error(`fake remote backend (${marker}) should not be called in this test`);
+ };
+ return {
+ spawn: fail,
+ readFile: fail,
+ writeFile: fail,
+ stat: fail,
+ readdir: fail,
+ exists: fail,
+ };
}
describe("ExecBackend resolver", () => {
- it("returns localExecBackend for computerId === undefined (local path unchanged)", () => {
- const services = new Map<string, unknown>();
- const host = createFakeHost(services);
-
- // Activate the extension so it registers its resolver, then retrieve it.
- createExecBackendExtension().activate(host);
- const resolver = host.getService(execBackendHandle);
-
- expect(resolver(undefined)).toBe(localExecBackend);
- expect(resolver()).toBe(localExecBackend);
- });
-
- it("returns the factory's backend for a set computerId when the factory is provided", () => {
- const services = new Map<string, unknown>();
- const host = createFakeHost(services);
-
- // The `ssh` extension (not built yet) would do this:
- const remoteBackend = createFakeRemoteBackend("ssh-alias");
- const factory = (computerId: string): ExecBackend => {
- // Confirm the alias is threaded through to the factory.
- expect(computerId).toBe("ssh-alias");
- return remoteBackend;
- };
- host.provideService(remoteExecBackendFactoryHandle, factory);
-
- createExecBackendExtension().activate(host);
- const resolver = host.getService(execBackendHandle);
-
- expect(resolver("ssh-alias")).toBe(remoteBackend);
- });
-
- it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => {
- const services = new Map<string, unknown>();
- const host = createFakeHost(services);
-
- // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded.
- createExecBackendExtension().activate(host);
- const resolver = host.getService(execBackendHandle);
-
- // Not a crash: a clear, actionable error mentioning computerId + ssh.
- expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/);
- expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/);
- expect(() => resolver("some-host")).toThrow(/some-host/);
- });
-
- it("local path is unaffected by whether the factory is provided", () => {
- // Even with a factory present, computerId === undefined still returns local.
- const services = new Map<string, unknown>();
- const host = createFakeHost(services);
- host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused"));
-
- createExecBackendExtension().activate(host);
- const resolver = host.getService(execBackendHandle);
-
- expect(resolver(undefined)).toBe(localExecBackend);
- });
+ it("returns localExecBackend for computerId === undefined (local path unchanged)", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // Activate the extension so it registers its resolver, then retrieve it.
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver(undefined)).toBe(localExecBackend);
+ expect(resolver()).toBe(localExecBackend);
+ });
+
+ it("returns the factory's backend for a set computerId when the factory is provided", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // The `ssh` extension (not built yet) would do this:
+ const remoteBackend = createFakeRemoteBackend("ssh-alias");
+ const factory = (computerId: string): ExecBackend => {
+ // Confirm the alias is threaded through to the factory.
+ expect(computerId).toBe("ssh-alias");
+ return remoteBackend;
+ };
+ host.provideService(remoteExecBackendFactoryHandle, factory);
+
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver("ssh-alias")).toBe(remoteBackend);
+ });
+
+ it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded.
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ // Not a crash: a clear, actionable error mentioning computerId + ssh.
+ expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/);
+ expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/);
+ expect(() => resolver("some-host")).toThrow(/some-host/);
+ });
+
+ it("local path is unaffected by whether the factory is provided", () => {
+ // Even with a factory present, computerId === undefined still returns local.
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+ host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused"));
+
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver(undefined)).toBe(localExecBackend);
+ });
});
diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts
index 9d6840a..5697842 100644
--- a/packages/exec-backend/src/extension.ts
+++ b/packages/exec-backend/src/extension.ts
@@ -2,19 +2,19 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import type { ExecBackend } from "./backend.js";
import { localExecBackend } from "./local.js";
import {
- type ExecBackendResolver,
- execBackendHandle,
- remoteExecBackendFactoryHandle,
+ type ExecBackendResolver,
+ execBackendHandle,
+ remoteExecBackendFactoryHandle,
} from "./service.js";
export const manifest: Manifest = {
- id: "exec-backend",
- name: "Exec Backend",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- contributes: { services: ["exec-backend/resolver"] },
+ id: "exec-backend",
+ name: "Exec Backend",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ contributes: { services: ["exec-backend/resolver"] },
};
/**
@@ -36,22 +36,22 @@ export const manifest: Manifest = {
* inside the first backend method call, not at resolve time.
*/
function createResolver(host: HostAPI): ExecBackendResolver {
- return (computerId?: string): ExecBackend => {
- if (computerId === undefined) return localExecBackend;
- // computerId set → remote. Look up the factory the `ssh` extension provides.
- // `host.getService` throws when nothing provided the handle (ssh not loaded);
- // convert that into a clear "not configured" error rather than a crash.
- let factory: (computerId: string) => ExecBackend;
- try {
- factory = host.getService(remoteExecBackendFactoryHandle);
- } catch {
- throw new Error(
- `SSH remote execution is not configured: the ssh extension is not loaded ` +
- `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`,
- );
- }
- return factory(computerId);
- };
+ return (computerId?: string): ExecBackend => {
+ if (computerId === undefined) return localExecBackend;
+ // computerId set → remote. Look up the factory the `ssh` extension provides.
+ // `host.getService` throws when nothing provided the handle (ssh not loaded);
+ // convert that into a clear "not configured" error rather than a crash.
+ let factory: (computerId: string) => ExecBackend;
+ try {
+ factory = host.getService(remoteExecBackendFactoryHandle);
+ } catch {
+ throw new Error(
+ `SSH remote execution is not configured: the ssh extension is not loaded ` +
+ `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`,
+ );
+ }
+ return factory(computerId);
+ };
}
/**
@@ -63,11 +63,11 @@ function createResolver(host: HostAPI): ExecBackendResolver {
* until `ssh` is loaded, a remote request fails with a clear error.
*/
export function createExecBackendExtension(): Extension {
- return {
- manifest,
- activate(host) {
- const resolver: ExecBackendResolver = createResolver(host);
- host.provideService(execBackendHandle, resolver);
- },
- };
+ return {
+ manifest,
+ activate(host) {
+ const resolver: ExecBackendResolver = createResolver(host);
+ host.provideService(execBackendHandle, resolver);
+ },
+ };
}
diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts
index 30c12c8..7c8e569 100644
--- a/packages/exec-backend/src/index.ts
+++ b/packages/exec-backend/src/index.ts
@@ -3,6 +3,6 @@ export { createExecBackendExtension, manifest } from "./extension.js";
export { createLocalExecBackend, localExecBackend } from "./local.js";
export type { ExecBackendResolver } from "./service.js";
export {
- execBackendHandle,
- remoteExecBackendFactoryHandle,
+ execBackendHandle,
+ remoteExecBackendFactoryHandle,
} from "./service.js";
diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts
index 5357d6f..7bf2ef5 100644
--- a/packages/exec-backend/src/local.test.ts
+++ b/packages/exec-backend/src/local.test.ts
@@ -10,190 +10,190 @@ import { createLocalExecBackend, localExecBackend } from "./local.js";
* (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*.
*/
describe("LocalExecBackend", () => {
- const backend: ExecBackend = createLocalExecBackend();
- let tmpDir: string;
-
- beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-"));
- });
-
- afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
- });
-
- describe("spawn", () => {
- it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => {
- let output = "";
- const result = await backend.spawn({
- command: "echo hi",
- cwd: tmpDir,
- signal: AbortSignal.timeout(5000),
- timeout: 5000,
- onOutput: (data) => {
- output += data;
- },
- });
- expect(result.exitCode).toBe(0);
- expect(result.timedOut).toBe(false);
- expect(result.aborted).toBe(false);
- expect(output).toContain("hi");
- });
-
- it("returns a non-zero exit code for a failing command", async () => {
- const result = await backend.spawn({
- command: "false",
- cwd: tmpDir,
- signal: AbortSignal.timeout(5000),
- timeout: 5000,
- onOutput: () => {},
- });
- expect(result.exitCode).toBe(1);
- expect(result.aborted).toBe(false);
- expect(result.timedOut).toBe(false);
- });
-
- it("streams stderr separately from stdout", async () => {
- const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
- const result = await backend.spawn({
- command: "echo out; echo err 1>&2",
- cwd: tmpDir,
- signal: AbortSignal.timeout(5000),
- timeout: 5000,
- onOutput: (data, stream) => streams.push({ data, stream }),
- });
- expect(result.exitCode).toBe(0);
- expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true);
- expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true);
- });
-
- it("resolves with aborted: true when the signal fires", async () => {
- const controller = new AbortController();
- const promise = backend.spawn({
- command: "sleep 30",
- cwd: tmpDir,
- signal: controller.signal,
- timeout: 60_000,
- onOutput: () => {},
- });
- // Let the sleep actually start.
- await new Promise((r) => setTimeout(r, 300));
- controller.abort();
- const result = await promise;
- expect(result.aborted).toBe(true);
- expect(result.timedOut).toBe(false);
- });
-
- it("resolves with timedOut: true when the timeout elapses", async () => {
- const start = Date.now();
- const result = await backend.spawn({
- command: "sleep 30",
- cwd: tmpDir,
- signal: AbortSignal.timeout(60_000),
- timeout: 300,
- onOutput: () => {},
- });
- const elapsed = Date.now() - start;
- expect(result.timedOut).toBe(true);
- expect(result.aborted).toBe(false);
- // Should resolve shortly after the 300ms timeout, well under 30s.
- expect(elapsed).toBeLessThan(10_000);
- });
- });
-
- describe("stat", () => {
- it("distinguishes file vs directory", async () => {
- await fsWriteFile(join(tmpDir, "file.txt"), "hello");
- await mkdir(join(tmpDir, "subdir"));
-
- const fileStat = await backend.stat(join(tmpDir, "file.txt"));
- expect(fileStat.isFile).toBe(true);
- expect(fileStat.isDirectory).toBe(false);
-
- const dirStat = await backend.stat(join(tmpDir, "subdir"));
- expect(dirStat.isFile).toBe(false);
- expect(dirStat.isDirectory).toBe(true);
- });
-
- it("throws ENOENT with .code for a missing path", async () => {
- try {
- await backend.stat(join(tmpDir, "nope"));
- expect.fail("stat should have thrown for a missing path");
- } catch (err: unknown) {
- expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
- }
- });
- });
-
- describe("readFile / writeFile / readdir / exists round-trip", () => {
- it("writes then reads a file (utf8 round-trip)", async () => {
- const filePath = join(tmpDir, "round.txt");
- await backend.writeFile(filePath, "round-trip content");
- const content = await backend.readFile(filePath);
- expect(content).toBe("round-trip content");
- });
-
- it("readdir lists entries with correct isDirectory flags", async () => {
- await fsWriteFile(join(tmpDir, "a.txt"), "a");
- await mkdir(join(tmpDir, "sub"));
-
- const entries = await backend.readdir(tmpDir);
- const names = entries.map((e) => e.name).sort();
- expect(names).toEqual(["a.txt", "sub"]);
-
- const sub = entries.find((e) => e.name === "sub");
- expect(sub?.isDirectory).toBe(true);
-
- const file = entries.find((e) => e.name === "a.txt");
- expect(file?.isDirectory).toBe(false);
- });
-
- it("exists returns true for an existing file, false for a missing one", async () => {
- const filePath = join(tmpDir, "exists.txt");
- await fsWriteFile(filePath, "x");
- expect(await backend.exists(filePath)).toBe(true);
- expect(await backend.exists(join(tmpDir, "missing"))).toBe(false);
- });
-
- it("exists returns true for an existing directory", async () => {
- await mkdir(join(tmpDir, "adir"));
- expect(await backend.exists(join(tmpDir, "adir"))).toBe(true);
- });
-
- it("readFile throws ENOENT with .code for a missing file", async () => {
- try {
- await backend.readFile(join(tmpDir, "missing.txt"));
- expect.fail("readFile should have thrown for a missing file");
- } catch (err: unknown) {
- expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
- }
- });
-
- it("readdir throws ENOENT with .code for a missing directory", async () => {
- try {
- await backend.readdir(join(tmpDir, "missingdir"));
- expect.fail("readdir should have thrown for a missing directory");
- } catch (err: unknown) {
- expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
- }
- });
-
- it("writeFile throws an error with .code when the parent dir is missing", async () => {
- try {
- await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x");
- expect.fail("writeFile should have thrown for a missing parent dir");
- } catch (err: unknown) {
- expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
- }
- });
- });
-
- describe("singleton", () => {
- it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => {
- expect(typeof localExecBackend.spawn).toBe("function");
- expect(typeof localExecBackend.readFile).toBe("function");
- const filePath = join(tmpDir, "singleton.txt");
- await localExecBackend.writeFile(filePath, "singleton");
- expect(await localExecBackend.readFile(filePath)).toBe("singleton");
- });
- });
+ const backend: ExecBackend = createLocalExecBackend();
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true });
+ });
+
+ describe("spawn", () => {
+ it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => {
+ let output = "";
+ const result = await backend.spawn({
+ command: "echo hi",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: (data) => {
+ output += data;
+ },
+ });
+ expect(result.exitCode).toBe(0);
+ expect(result.timedOut).toBe(false);
+ expect(result.aborted).toBe(false);
+ expect(output).toContain("hi");
+ });
+
+ it("returns a non-zero exit code for a failing command", async () => {
+ const result = await backend.spawn({
+ command: "false",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: () => {},
+ });
+ expect(result.exitCode).toBe(1);
+ expect(result.aborted).toBe(false);
+ expect(result.timedOut).toBe(false);
+ });
+
+ it("streams stderr separately from stdout", async () => {
+ const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
+ const result = await backend.spawn({
+ command: "echo out; echo err 1>&2",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: (data, stream) => streams.push({ data, stream }),
+ });
+ expect(result.exitCode).toBe(0);
+ expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true);
+ expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true);
+ });
+
+ it("resolves with aborted: true when the signal fires", async () => {
+ const controller = new AbortController();
+ const promise = backend.spawn({
+ command: "sleep 30",
+ cwd: tmpDir,
+ signal: controller.signal,
+ timeout: 60_000,
+ onOutput: () => {},
+ });
+ // Let the sleep actually start.
+ await new Promise((r) => setTimeout(r, 300));
+ controller.abort();
+ const result = await promise;
+ expect(result.aborted).toBe(true);
+ expect(result.timedOut).toBe(false);
+ });
+
+ it("resolves with timedOut: true when the timeout elapses", async () => {
+ const start = Date.now();
+ const result = await backend.spawn({
+ command: "sleep 30",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(60_000),
+ timeout: 300,
+ onOutput: () => {},
+ });
+ const elapsed = Date.now() - start;
+ expect(result.timedOut).toBe(true);
+ expect(result.aborted).toBe(false);
+ // Should resolve shortly after the 300ms timeout, well under 30s.
+ expect(elapsed).toBeLessThan(10_000);
+ });
+ });
+
+ describe("stat", () => {
+ it("distinguishes file vs directory", async () => {
+ await fsWriteFile(join(tmpDir, "file.txt"), "hello");
+ await mkdir(join(tmpDir, "subdir"));
+
+ const fileStat = await backend.stat(join(tmpDir, "file.txt"));
+ expect(fileStat.isFile).toBe(true);
+ expect(fileStat.isDirectory).toBe(false);
+
+ const dirStat = await backend.stat(join(tmpDir, "subdir"));
+ expect(dirStat.isFile).toBe(false);
+ expect(dirStat.isDirectory).toBe(true);
+ });
+
+ it("throws ENOENT with .code for a missing path", async () => {
+ try {
+ await backend.stat(join(tmpDir, "nope"));
+ expect.fail("stat should have thrown for a missing path");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+ });
+
+ describe("readFile / writeFile / readdir / exists round-trip", () => {
+ it("writes then reads a file (utf8 round-trip)", async () => {
+ const filePath = join(tmpDir, "round.txt");
+ await backend.writeFile(filePath, "round-trip content");
+ const content = await backend.readFile(filePath);
+ expect(content).toBe("round-trip content");
+ });
+
+ it("readdir lists entries with correct isDirectory flags", async () => {
+ await fsWriteFile(join(tmpDir, "a.txt"), "a");
+ await mkdir(join(tmpDir, "sub"));
+
+ const entries = await backend.readdir(tmpDir);
+ const names = entries.map((e) => e.name).sort();
+ expect(names).toEqual(["a.txt", "sub"]);
+
+ const sub = entries.find((e) => e.name === "sub");
+ expect(sub?.isDirectory).toBe(true);
+
+ const file = entries.find((e) => e.name === "a.txt");
+ expect(file?.isDirectory).toBe(false);
+ });
+
+ it("exists returns true for an existing file, false for a missing one", async () => {
+ const filePath = join(tmpDir, "exists.txt");
+ await fsWriteFile(filePath, "x");
+ expect(await backend.exists(filePath)).toBe(true);
+ expect(await backend.exists(join(tmpDir, "missing"))).toBe(false);
+ });
+
+ it("exists returns true for an existing directory", async () => {
+ await mkdir(join(tmpDir, "adir"));
+ expect(await backend.exists(join(tmpDir, "adir"))).toBe(true);
+ });
+
+ it("readFile throws ENOENT with .code for a missing file", async () => {
+ try {
+ await backend.readFile(join(tmpDir, "missing.txt"));
+ expect.fail("readFile should have thrown for a missing file");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+
+ it("readdir throws ENOENT with .code for a missing directory", async () => {
+ try {
+ await backend.readdir(join(tmpDir, "missingdir"));
+ expect.fail("readdir should have thrown for a missing directory");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+
+ it("writeFile throws an error with .code when the parent dir is missing", async () => {
+ try {
+ await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x");
+ expect.fail("writeFile should have thrown for a missing parent dir");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+ });
+
+ describe("singleton", () => {
+ it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => {
+ expect(typeof localExecBackend.spawn).toBe("function");
+ expect(typeof localExecBackend.readFile).toBe("function");
+ const filePath = join(tmpDir, "singleton.txt");
+ await localExecBackend.writeFile(filePath, "singleton");
+ expect(await localExecBackend.readFile(filePath)).toBe("singleton");
+ });
+ });
});
diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts
index ca88a11..1812e5d 100644
--- a/packages/exec-backend/src/local.ts
+++ b/packages/exec-backend/src/local.ts
@@ -20,32 +20,32 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from
* share as a singleton.
*/
export function createLocalExecBackend(): ExecBackend {
- return {
- spawn: localSpawn,
-
- readFile: (path) => readFile(path, "utf8"),
-
- writeFile: (path, content) => writeFile(path, content, "utf8"),
-
- stat: async (path): Promise<StatResult> => {
- const s = await stat(path);
- return { isFile: s.isFile(), isDirectory: s.isDirectory() };
- },
-
- readdir: async (path): Promise<readonly DirEntry[]> => {
- const entries = await readdir(path, { encoding: "utf8", withFileTypes: true });
- return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() }));
- },
-
- exists: async (path): Promise<boolean> => {
- try {
- await access(path);
- return true;
- } catch {
- return false;
- }
- },
- };
+ return {
+ spawn: localSpawn,
+
+ readFile: (path) => readFile(path, "utf8"),
+
+ writeFile: (path, content) => writeFile(path, content, "utf8"),
+
+ stat: async (path): Promise<StatResult> => {
+ const s = await stat(path);
+ return { isFile: s.isFile(), isDirectory: s.isDirectory() };
+ },
+
+ readdir: async (path): Promise<readonly DirEntry[]> => {
+ const entries = await readdir(path, { encoding: "utf8", withFileTypes: true });
+ return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() }));
+ },
+
+ exists: async (path): Promise<boolean> => {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ };
}
/** Default singleton — stateless, safe to share across calls. */
@@ -61,86 +61,86 @@ export const localExecBackend: ExecBackend = createLocalExecBackend();
* listener/timer leaks.
*/
function localSpawn(params: SpawnParams): Promise<ExecResult> {
- return new Promise<ExecResult>((resolve) => {
- // detached: true puts the child in its own process group (pgid = child.pid).
- // This lets us kill the entire group (child + any grandchildren that inherit
- // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
- // backgrounded grandchild can't keep the stdio pipes open and stall the
- // promise on child.on("close").
- const child = nodeSpawn("sh", ["-c", params.command], {
- cwd: params.cwd,
- stdio: ["ignore", "pipe", "pipe"],
- detached: true,
- });
-
- let settled = false;
- let timedOut = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
-
- /** Kill the entire child process group (best-effort — group may be gone). */
- const killGroup = () => {
- if (child.pid !== undefined) {
- try {
- process.kill(-child.pid, "SIGKILL");
- } catch {
- // Process group may already be gone — ignore.
- }
- }
- };
-
- /** Remove the abort listener and clear the timeout timer (no leaks). */
- const cleanup = () => {
- if (timer !== undefined) {
- clearTimeout(timer);
- timer = undefined;
- }
- params.signal.removeEventListener("abort", onAbort);
- };
-
- /** Resolve once, then clean up so listeners/timers never leak. */
- const settle = (result: ExecResult) => {
- if (settled) return;
- settled = true;
- cleanup();
- resolve(result);
- };
-
- const onAbort = () => {
- if (settled) return;
- killGroup();
- // Resolve immediately — do NOT wait for child.on("close"), which may
- // never fire if a grandchild holds the pipes open.
- settle({ exitCode: null, timedOut: false, aborted: true });
- };
- params.signal.addEventListener("abort", onAbort, { once: true });
-
- timer = setTimeout(() => {
- if (settled) return;
- timedOut = true;
- killGroup();
- // Resolve immediately — same reasoning as abort.
- settle({ exitCode: null, timedOut: true, aborted: false });
- }, params.timeout);
-
- child.stdout.on("data", (chunk: Buffer) => {
- params.onOutput(chunk.toString(), "stdout");
- });
-
- child.stderr.on("data", (chunk: Buffer) => {
- params.onOutput(chunk.toString(), "stderr");
- });
-
- // Normal-completion path: wait for "close" so all stdout/stderr is captured.
- // If abort/timeout already settled, this is a no-op (settled === true).
- child.on("close", (code) => {
- settle({ exitCode: code, timedOut, aborted: false });
- });
-
- // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
- // and resolve — never leave the promise pending.
- child.on("error", () => {
- killGroup();
- settle({ exitCode: 1, timedOut: false, aborted: false });
- });
- });
+ return new Promise<ExecResult>((resolve) => {
+ // detached: true puts the child in its own process group (pgid = child.pid).
+ // This lets us kill the entire group (child + any grandchildren that inherit
+ // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
+ // backgrounded grandchild can't keep the stdio pipes open and stall the
+ // promise on child.on("close").
+ const child = nodeSpawn("sh", ["-c", params.command], {
+ cwd: params.cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
+ });
+
+ let settled = false;
+ let timedOut = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+
+ /** Kill the entire child process group (best-effort — group may be gone). */
+ const killGroup = () => {
+ if (child.pid !== undefined) {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ // Process group may already be gone — ignore.
+ }
+ }
+ };
+
+ /** Remove the abort listener and clear the timeout timer (no leaks). */
+ const cleanup = () => {
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ params.signal.removeEventListener("abort", onAbort);
+ };
+
+ /** Resolve once, then clean up so listeners/timers never leak. */
+ const settle = (result: ExecResult) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(result);
+ };
+
+ const onAbort = () => {
+ if (settled) return;
+ killGroup();
+ // Resolve immediately — do NOT wait for child.on("close"), which may
+ // never fire if a grandchild holds the pipes open.
+ settle({ exitCode: null, timedOut: false, aborted: true });
+ };
+ params.signal.addEventListener("abort", onAbort, { once: true });
+
+ timer = setTimeout(() => {
+ if (settled) return;
+ timedOut = true;
+ killGroup();
+ // Resolve immediately — same reasoning as abort.
+ settle({ exitCode: null, timedOut: true, aborted: false });
+ }, params.timeout);
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stdout");
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stderr");
+ });
+
+ // Normal-completion path: wait for "close" so all stdout/stderr is captured.
+ // If abort/timeout already settled, this is a no-op (settled === true).
+ child.on("close", (code) => {
+ settle({ exitCode: code, timedOut, aborted: false });
+ });
+
+ // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
+ // and resolve — never leave the promise pending.
+ child.on("error", () => {
+ killGroup();
+ settle({ exitCode: 1, timedOut: false, aborted: false });
+ });
+ });
}
diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts
index 6cfa9de..37009c0 100644
--- a/packages/exec-backend/src/service.ts
+++ b/packages/exec-backend/src/service.ts
@@ -42,5 +42,5 @@ export const execBackendHandle = defineService<ExecBackendResolver>("exec-backen
* rather than crashing activation.
*/
export const remoteExecBackendFactoryHandle = defineService<(computerId: string) => ExecBackend>(
- "exec-backend/remote-factory",
+ "exec-backend/remote-factory",
);