mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
When OS policy prevents a reliable process or port check, daemon startup now reports the uncertainty instead of treating a blocked probe as proof that an address is free. Missing state and preflight cases warn. The auto-start stop is narrowed to the shared PID path for a reused or uninspectable PID. Stop refuses unknown process liveness before probing or signalling and preserves the state file. A denied signal probe yields unknown even when the process state is readable, and a confirmed zombie remains dead. A blocked explicit non-loopback OPENRIG_URL stays on the blocked-status path instead of starting a local daemon (rig up and rig start); loopback targets, including localhost, [::1] and 127.x addresses, keep the existing startup behaviour. Refs #275 and #1650. Contributed by dajiaohuang.
1917 lines
86 KiB
TypeScript
1917 lines
86 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||
import http from "node:http";
|
||
import fs from "node:fs";
|
||
import os from "node:os";
|
||
import nodePath from "node:path";
|
||
import { EventEmitter } from "node:events";
|
||
import { Command } from "commander";
|
||
import { formatRemoteUpFailure, upCommand } from "../src/commands/up.js";
|
||
import { DaemonClient } from "../src/client.js";
|
||
import { LOG_FILE, STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||
import type { StatusDeps } from "../src/commands/status.js";
|
||
|
||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||
return {
|
||
acquireStartLock: () => ({ recordChild: vi.fn(), release: vi.fn() }),
|
||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||
fetch: vi.fn(async () => ({ ok: true })),
|
||
kill: vi.fn(() => true),
|
||
readFile: vi.fn(() => null),
|
||
writeFile: vi.fn(),
|
||
removeFile: vi.fn(),
|
||
exists: vi.fn(() => false),
|
||
mkdirp: vi.fn(),
|
||
openForAppend: vi.fn(() => 3),
|
||
isProcessAlive: vi.fn(() => true),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||
return new Promise(async (resolve) => {
|
||
const logs: string[] = [];
|
||
const origLog = console.log;
|
||
const origErr = console.error;
|
||
const origExitCode = process.exitCode;
|
||
process.exitCode = undefined;
|
||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
||
const exitCode = process.exitCode;
|
||
process.exitCode = origExitCode;
|
||
resolve({ logs, exitCode });
|
||
});
|
||
}
|
||
|
||
function runningDeps(port: number): StatusDeps {
|
||
return {
|
||
lifecycleDeps: mockLifecycleDeps({
|
||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||
readFile: vi.fn((p: string) => {
|
||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-03-26T00:00:00Z" } as DaemonState);
|
||
return null;
|
||
}),
|
||
fetch: vi.fn(async () => ({ ok: true })),
|
||
}),
|
||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||
};
|
||
}
|
||
|
||
function healthyPreflightExec(cmd: string): Promise<string> {
|
||
if (cmd === "tmux -V") return Promise.resolve("tmux 3.6a");
|
||
if (cmd === "tmux list-sessions") return Promise.resolve("");
|
||
return Promise.resolve("");
|
||
}
|
||
|
||
describe("Up CLI", () => {
|
||
let server: http.Server;
|
||
let port: number;
|
||
|
||
beforeAll(async () => {
|
||
server = http.createServer(async (req, res) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
const parsed = JSON.parse(body);
|
||
if (String(parsed.sourceRef).includes("reinstall-cause")) {
|
||
const code = String(parsed.sourceRef).match(/reinstall-cause-([\w]+)\.rigbundle$/)?.[1];
|
||
res.writeHead(code ? 400 : 500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "failed", code, errors: ["Install target has different content at README.md"], warnings: ["Original files are kept at /fixture/backup"], stages: [] }));
|
||
return;
|
||
}
|
||
if (parsed.plan) {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "planned", runId: "run-1", stages: [{ stage: "resolve_spec", status: "ok" }], errors: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1", stages: [{ stage: "resolve_spec", status: "ok" }, { stage: "import_rig", status: "ok" }], errors: [], warnings: [], attachCommand: "tmux attach -t dev-impl@test-rig" }));
|
||
}
|
||
} else {
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "not found" }));
|
||
}
|
||
});
|
||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||
port = (server.address() as { port: number }).port;
|
||
});
|
||
|
||
afterAll(() => { server.close(); });
|
||
|
||
function makeCmd(): Command {
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand(runningDeps(port)));
|
||
return prog;
|
||
}
|
||
|
||
it("local bundle reinstall renders errors[] and file disposition instead of unknown error", async () => {
|
||
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "up", "reinstall-cause.rigbundle"]).then(() => {}));
|
||
expect(exitCode).toBe(2);
|
||
expect(logs.join("\n")).toContain("different content at README.md");
|
||
expect(logs.join("\n")).toContain("Original files are kept at /fixture/backup");
|
||
expect(logs.join("\n")).not.toContain("unknown error");
|
||
});
|
||
|
||
it.each(["validation_failed", "preflight_failed", "cycle_error", "generation_unconfirmed", "target_conflict"])("round 2: %s prints file disposition once", async code => {
|
||
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "up", `reinstall-cause-${code}.rigbundle`]).then(() => {}));
|
||
expect(exitCode).toBe(2);
|
||
expect(logs.join("\n").match(/Original files are kept at \/fixture\/backup/g)).toHaveLength(1);
|
||
});
|
||
|
||
it("renders structured remote up failures and attention hints as text", () => {
|
||
const lines = formatRemoteUpFailure("build-host", {
|
||
error: "HTTP 409",
|
||
data: {
|
||
error: {
|
||
fact: "Seat trust approval is required.",
|
||
consequence: "The rig was not fully started.",
|
||
action: "Attach to the affected seat and approve the prompt.",
|
||
},
|
||
attentionNodes: [{ logicalId: "worker", sessionName: "worker@demo", reason: "hook trust" }],
|
||
},
|
||
});
|
||
|
||
expect(lines.join("\n")).toContain("Error on host build-host: HTTP 409");
|
||
expect(lines.join("\n")).toContain("Seat trust approval is required.");
|
||
expect(lines.join("\n")).toContain("worker (worker@demo): hook trust");
|
||
expect(lines.join("\n")).not.toContain("[object Object]");
|
||
});
|
||
|
||
it("renders message-only and partial remote error bodies without unsafe stringification", () => {
|
||
expect(formatRemoteUpFailure("build-host", {
|
||
error: "HTTP 500", data: { error: "daemon startup failed", code: "daemon_start_failed" },
|
||
}).join("\n")).toContain("daemon startup failed");
|
||
expect(formatRemoteUpFailure("build-host", {
|
||
error: "HTTP 502", data: { error: { fact: "Remote proxy rejected the request." } },
|
||
}).join("\n")).toContain("Error: Remote proxy rejected the request.");
|
||
const malformed = formatRemoteUpFailure("build-host", { error: "HTTP 502", data: { error: { unknown: true } } });
|
||
expect(malformed.join("\n")).toContain("did not return a recognized error message");
|
||
expect(malformed.join("\n")).not.toContain("[object Object]");
|
||
});
|
||
|
||
it("help positions managed apps as first-class launch targets", () => {
|
||
const logs: string[] = [];
|
||
const cmd = makeCmd().commands.find((c) => c.name() === "up")!;
|
||
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
|
||
cmd.outputHelp();
|
||
const help = logs.join("");
|
||
expect(help).toContain("Launch a rig or managed app from a spec, library entry, or bundle");
|
||
const flat = help.replace(/\s+/g, " ");
|
||
expect(flat).toContain("Install target for a .rigbundle (default: current directory)");
|
||
expect(flat).toContain("relative member cwds resolve against it; --cwd still overrides launch cwd");
|
||
expect(flat).not.toContain("does not change agent cwd");
|
||
expect(help).toContain("--cwd <path>");
|
||
expect(help).toContain("rig up secrets-manager");
|
||
});
|
||
|
||
// T7: up from .yaml -> stages + rig ID
|
||
it("up prints stages and rig ID", async () => {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
expect(logs.some((l) => l.includes("resolve_spec"))).toBe(true);
|
||
expect(logs.some((l) => l.includes("rig-1"))).toBe(true);
|
||
expect(logs.some((l) => l.includes("completed"))).toBe(true);
|
||
});
|
||
|
||
// T8: up --plan
|
||
it("up --plan prints planned status", async () => {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--plan"]);
|
||
});
|
||
expect(logs.some((l) => l.includes("planned"))).toBe(true);
|
||
});
|
||
|
||
// T9: --yes sends autoApprove
|
||
it("up --yes sends autoApprove=true", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
return;
|
||
}
|
||
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
|
||
// call for install-root awareness; respond with a 404 so the CLI
|
||
// falls through (this test does not exercise the install-internal
|
||
// path-form gate).
|
||
res.writeHead(404).end();
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--yes"]);
|
||
});
|
||
|
||
expect(lastBody.autoApprove).toBe(true);
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
// T10: --json
|
||
it("up --json outputs parseable JSON", async () => {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--json"]);
|
||
});
|
||
const parsed = JSON.parse(logs.join(""));
|
||
expect(parsed.status).toBe("completed");
|
||
});
|
||
|
||
// T12: Failure -> exit 2
|
||
it("failure response returns exit 2", async () => {
|
||
const failServer = http.createServer((_, res) => {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "failed", error: "boom", stages: [], errors: ["boom"] }));
|
||
});
|
||
await new Promise<void>((resolve) => { failServer.listen(0, resolve); });
|
||
const failPort = (failServer.address() as { port: number }).port;
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand(runningDeps(failPort)));
|
||
|
||
const { exitCode } = await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
|
||
expect(exitCode).toBe(2);
|
||
failServer.close();
|
||
});
|
||
|
||
it("agent_ref resolution failures include local-ref guidance", async () => {
|
||
const failServer = http.createServer((_, res) => {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({
|
||
status: "failed",
|
||
error: "dev.impl: agent_ref resolution failed: No agent.yaml found at /tmp/agents/impl/agent.yaml",
|
||
stages: [],
|
||
errors: [],
|
||
}));
|
||
});
|
||
await new Promise<void>((resolve) => { failServer.listen(0, resolve); });
|
||
const failPort = (failServer.address() as { port: number }).port;
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand(runningDeps(failPort)));
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("agent_ref resolution failed");
|
||
expect(output).toContain("local: agent_ref paths resolve relative to the rig spec directory");
|
||
expect(exitCode).toBe(2);
|
||
failServer.close();
|
||
});
|
||
|
||
it("prints the Compose project conflict and retry instruction without generic boot failure", async () => {
|
||
const message = "Replacement has multiple predecessor Compose projects (rig-1: old; rig-2: other). Set services.project_name in the rig spec YAML to the project you intend to use, then re-run the same command. No services were started.";
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...runningDeps(port), clientFactory: () => ({
|
||
post: async () => ({ status: 400, data: { status: "failed", code: "compose_project_conflict", stages: [], errors: [message] } }),
|
||
} as unknown as DaemonClient) }));
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
expect(logs.join("\n")).toContain(message);
|
||
expect(logs.join("\n")).not.toContain("unknown error");
|
||
expect(exitCode).toBe(2);
|
||
});
|
||
|
||
// T13: Relative path resolved to absolute before sending
|
||
it("resolves relative path to absolute in POST body", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
return;
|
||
}
|
||
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
|
||
res.writeHead(404).end();
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "relative/spec.yaml"]);
|
||
});
|
||
|
||
// sourceRef must be an absolute path, not the raw relative input
|
||
expect(lastBody.sourceRef).toMatch(/^\//);
|
||
expect((lastBody.sourceRef as string).endsWith("relative/spec.yaml")).toBe(true);
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up from .rigbundle defaults targetRoot to current working directory", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle"]);
|
||
});
|
||
|
||
expect(lastBody.sourceRef).toBe("/tmp/demo.rigbundle");
|
||
expect(lastBody.targetRoot).toBe(process.cwd());
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up from .rigbundle preserves explicit --target", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--target", "/tmp/custom-root"]);
|
||
});
|
||
|
||
expect(lastBody.targetRoot).toBe("/tmp/custom-root");
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up from .rigbundle resolves a relative --target against the client cwd on the local route", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--target", "rel/target-root"]);
|
||
});
|
||
|
||
expect(lastBody.targetRoot).toBe(`${process.cwd()}/rel/target-root`);
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up from .rigbundle with --cwd and no --target keeps the default target and sends --cwd only as cwdOverride", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--cwd", "rel/launch-dir"]);
|
||
});
|
||
|
||
expect(lastBody.targetRoot).toBe(process.cwd());
|
||
expect(lastBody.cwdOverride).toBe(`${process.cwd()}/rel/launch-dir`);
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up from a library name defaults cwdOverride to the caller working directory", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
|
||
if ((req.url ?? "").startsWith("/api/specs/library") && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([
|
||
{
|
||
id: "builtin:rig:demo",
|
||
kind: "rig",
|
||
name: "demo",
|
||
version: "0.0.0",
|
||
sourceType: "builtin",
|
||
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml",
|
||
relativePath: "rigs/launch/demo/rig.yaml",
|
||
updatedAt: "2026-04-10T00:00:00Z",
|
||
},
|
||
]));
|
||
return;
|
||
}
|
||
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
return;
|
||
}
|
||
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
return;
|
||
}
|
||
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "not found" }));
|
||
});
|
||
|
||
try {
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "demo"]);
|
||
});
|
||
|
||
expect(lastBody.sourceRef).toBe("/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml");
|
||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
});
|
||
|
||
// #226: a launch from '/' continues, and the person is told why their agents would work in '/'.
|
||
// Only the cwd the CLI defaults warns; an explicit --cwd / is the person's choice.
|
||
it.each([
|
||
{ args: ["demo"], warns: true },
|
||
{ args: ["demo", "--cwd", "/"], warns: false },
|
||
])("up $args from / sends cwdOverride / and warns only when / was defaulted", async ({ args, warns }) => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
const reply = (status: number, data: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
|
||
if ((req.url ?? "").startsWith("/api/specs/library")) return reply(200, [{
|
||
id: "builtin:rig:demo", kind: "rig", name: "demo", version: "0.0.0", sourceType: "builtin",
|
||
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml", relativePath: "rigs/launch/demo/rig.yaml", updatedAt: "2026-04-10T00:00:00Z",
|
||
}]);
|
||
if (req.url === "/api/rigs/summary") return reply(200, []);
|
||
if (req.url === "/api/up") { lastBody = JSON.parse(body); return reply(201, { status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }); }
|
||
reply(404, { error: "not found" });
|
||
});
|
||
const cwd = vi.spyOn(process, "cwd").mockReturnValue("/");
|
||
try {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", ...args]);
|
||
});
|
||
expect(lastBody.cwdOverride).toBe("/");
|
||
expect(exitCode).toBeUndefined();
|
||
expect(logs.some((l) => l.includes("warning:") && l.includes("--cwd <project dir>"))).toBe(warns);
|
||
} finally {
|
||
cwd.mockRestore();
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
});
|
||
|
||
it("up --cwd sends absolute cwdOverride", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--cwd", "relative/project"]);
|
||
});
|
||
|
||
expect(lastBody.cwdOverride).toMatch(/^\//);
|
||
expect((lastBody.cwdOverride as string).endsWith("relative/project")).toBe(true);
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up --env sends each KEY=VALUE as envOverride, splitting at the first equals sign", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "DEBUG=1", "--env", "URL=http://x/?a=b"]);
|
||
});
|
||
|
||
expect(lastBody.envOverride).toEqual({ DEBUG: "1", URL: "http://x/?a=b" });
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up --env leaves out CLAUDE_CONFIG_DIR and CODEX_HOME with a note, and still sends the rest", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
try {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "CODEX_HOME=/homes/b", "--env", "CLAUDE_CONFIG_DIR=/homes/c", "--env", "DEBUG=1"]);
|
||
});
|
||
expect(lastBody.envOverride).toEqual({ DEBUG: "1" });
|
||
expect(logs.join("\n")).toContain("--env CODEX_HOME isn't applied");
|
||
expect(logs.join("\n")).toContain("--env CLAUDE_CONFIG_DIR isn't applied");
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
});
|
||
|
||
it("up without --env sends no envOverride", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
}
|
||
});
|
||
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
|
||
expect(lastBody).not.toHaveProperty("envOverride");
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
it("up --env without an equals sign stops before contacting the daemon", async () => {
|
||
let called = false;
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", (_req: http.IncomingMessage, res: http.ServerResponse) => { called = true; res.writeHead(500); res.end(); });
|
||
try {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "NOEQUALS"]);
|
||
});
|
||
expect(called).toBe(false);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain("--env expects KEY=VALUE");
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
});
|
||
|
||
async function withListener(handler: (url: string) => { status: number; body: unknown }, run: (upCalls: () => number) => Promise<void>) {
|
||
let upCalls = 0;
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
for await (const _chunk of req) { /* drain */ }
|
||
if (req.url === "/api/up") upCalls++;
|
||
const { status, body } = handler(req.url ?? "");
|
||
res.writeHead(status, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify(body));
|
||
});
|
||
try {
|
||
await run(() => upCalls);
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
}
|
||
|
||
it.each([
|
||
["a lowercase name", "lower=x", "not a valid name"],
|
||
["a reserved name", "OPENRIG_URL=http://elsewhere", "reserved"],
|
||
["an empty value", "EMPTY=", "must not be empty"],
|
||
])("up --env with %s stops before contacting the daemon", async (_label, assignment, fragment) => {
|
||
await withListener(() => ({ status: 500, body: {} }), async (upCalls) => {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", assignment]);
|
||
});
|
||
expect(upCalls()).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain(fragment);
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
["a rig name", ["up", "my-rig", "--env", "DEBUG=1"]],
|
||
["a rig name with --existing", ["up", "my-rig", "--existing", "--env", "DEBUG=1"]],
|
||
])("up --env with %s says a restore takes no --env and does not call /api/up", async (_label, args) => {
|
||
await withListener((url) => ({ status: 200, body: url.startsWith("/api/rigs/summary") ? [{ id: "r1", name: "my-rig", lifecycleState: "stopped" }] : [] }), async (upCalls) => {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", ...args]);
|
||
});
|
||
expect(upCalls()).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain("A restore keeps the rig's own env: block");
|
||
});
|
||
});
|
||
|
||
it("up --env with a topology source says a topology takes no --env and does not call /api/up", async () => {
|
||
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "up-topology-env-"));
|
||
try {
|
||
const manifest = nodePath.join(dir, "factory.rigtopology");
|
||
fs.writeFileSync(manifest, "rigs:\n - source: ./a.yaml\n");
|
||
await withListener(() => ({ status: 200, body: {} }), async (upCalls) => {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", manifest, "--env", "DEBUG=1"]);
|
||
});
|
||
expect(upCalls()).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain("A topology launches several rigs");
|
||
});
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("up without --name sends no rigName", async () => {
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up") lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
});
|
||
try {
|
||
await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
}
|
||
expect(lastBody).toHaveProperty("sourceRef");
|
||
expect(lastBody).not.toHaveProperty("rigName");
|
||
});
|
||
|
||
it.each([
|
||
["a rig name", ["up", "my-rig", "--name", "alpha"], "renaming an existing rig"],
|
||
["--existing", ["up", "my-rig", "--existing", "--name", "alpha"], "renaming an existing rig"],
|
||
["an empty name", ["up", "/tmp/rig.yaml", "--name", ""], "--name needs a rig name"],
|
||
])("up --name with %s is refused and does not call /api/up", async (_label, args, fragment) => {
|
||
await withListener((url) => ({ status: 200, body: url.startsWith("/api/rigs/summary") ? [{ id: "r1", name: "my-rig", lifecycleState: "stopped" }] : [] }), async (upCalls) => {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", ...args]);
|
||
});
|
||
expect(upCalls()).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain(fragment);
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
["a topology", "factory.rigtopology", "A topology launches several rigs"],
|
||
["a GitHub bundle link", "https://github.com/acme/rigs/tree/main/team", "GitHub bundle link"],
|
||
])("up <%s> --name is refused and does not call /api/up", async (_label, source, fragment) => {
|
||
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "up-name-"));
|
||
try {
|
||
const target = source.endsWith(".rigtopology") ? nodePath.join(dir, source) : source;
|
||
if (target !== source) fs.writeFileSync(target, "rigs:\n - source: ./a.yaml\n");
|
||
await withListener(() => ({ status: 200, body: {} }), async (upCalls) => {
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", target, "--name", "alpha"]);
|
||
});
|
||
expect(upCalls()).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
expect(logs.join("\n")).toContain(fragment);
|
||
});
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
|
||
// --cwd should default cwdOverride to process.cwd() so the spec's
|
||
// member-level cwd: "." does not resolve into the OpenRig install root
|
||
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
|
||
// is already rescued at the resolveLibrarySpec branch (covered by the
|
||
// earlier library-name test); this pins the path-form gap.
|
||
it.each(["rigs", "..cache"])("up path-form spec inside install root (%s) defaults cwdOverride to caller cwd and prints notice", async (directory) => {
|
||
const fs = await import("node:fs");
|
||
const path = await import("node:path");
|
||
const os = await import("node:os");
|
||
|
||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||
const specPath = path.join(installRoot, directory, "demo", "rig.yaml");
|
||
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
||
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
|
||
|
||
let lastBody: Record<string, unknown> = {};
|
||
let infoCalls = 0;
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/info" && req.method === "GET") {
|
||
infoCalls += 1;
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ installRoot }));
|
||
return;
|
||
}
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "not found" }));
|
||
});
|
||
|
||
try {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||
});
|
||
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
|
||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
|
||
const fs = await import("node:fs");
|
||
const path = await import("node:path");
|
||
const os = await import("node:os");
|
||
|
||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
|
||
const specPath = path.join(projectRoot, "rig.yaml");
|
||
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
|
||
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/info" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ installRoot }));
|
||
return;
|
||
}
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
return;
|
||
}
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: "not found" }));
|
||
});
|
||
|
||
try {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||
});
|
||
expect(lastBody.cwdOverride).toBeUndefined();
|
||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("up apply uses a long-running daemon timeout budget", async () => {
|
||
let timeoutMs: number | undefined;
|
||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||
timeoutMs = options?.timeoutMs;
|
||
return {
|
||
status: 201,
|
||
data: { status: "completed", runId: "r", rigId: "g", stages: [], errors: [], warnings: [] },
|
||
};
|
||
});
|
||
|
||
const deps: StatusDeps = {
|
||
lifecycleDeps: runningDeps(port).lifecycleDeps,
|
||
clientFactory: () => ({ post } as unknown as DaemonClient),
|
||
};
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
|
||
await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||
});
|
||
|
||
expect(timeoutMs).toBe(120_000);
|
||
});
|
||
|
||
// NS-T14: fresh boot handoff includes dashboard URL + attach command
|
||
it("fresh boot success shows dashboard URL and attach command", async () => {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
});
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("Dashboard: rig ui open");
|
||
expect(output).toContain("Attach:");
|
||
expect(output).toContain("tmux attach -t dev-impl@test-rig");
|
||
});
|
||
|
||
it("restored rig output prints degraded rig result and exits nonzero", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "rig-1", name: "restore-me", nodeCount: 1 }]));
|
||
return;
|
||
}
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({
|
||
status: "restored",
|
||
rigId: "rig-1",
|
||
rigName: "restore-me",
|
||
rigResult: "partially_restored",
|
||
nodes: [{ logicalId: "worker", status: "fresh" }],
|
||
warnings: [],
|
||
}));
|
||
return;
|
||
}
|
||
res.writeHead(404).end();
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "restore-me"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("Result: partially_restored");
|
||
expect(output).toContain("worker: fresh");
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
it("restored rig validation block prints not_attempted blockers and exits nonzero", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "rig-1", name: "restore-me", nodeCount: 1 }]));
|
||
return;
|
||
}
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(409, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({
|
||
status: "not_attempted",
|
||
rigId: "rig-1",
|
||
rigName: "restore-me",
|
||
error: "Restore pre-validation failed; no restore mutation was attempted.",
|
||
code: "pre_restore_validation_failed",
|
||
snapshotId: "snap-1",
|
||
preRestoreSnapshotId: null,
|
||
rigResult: "not_attempted",
|
||
nodes: [],
|
||
warnings: [],
|
||
blockers: [{
|
||
code: "required_startup_file_missing",
|
||
severity: "critical",
|
||
logicalId: "worker",
|
||
path: "/workspace/app/STARTUP.md",
|
||
message: "Required startup file is missing for worker: /workspace/app/STARTUP.md",
|
||
remediation: "Restore the missing startup file or capture a new snapshot before retrying restore.",
|
||
}],
|
||
}));
|
||
return;
|
||
}
|
||
res.writeHead(404).end();
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "restore-me"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("Result: not_attempted");
|
||
expect(output).toContain("Required startup file is missing");
|
||
expect(output).toContain("/workspace/app/STARTUP.md");
|
||
expect(output).toContain("Restore the missing startup file");
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
// PNS-T06: fresh boot warnings (e.g. transcript attach failures) surface to stderr
|
||
it("fresh boot success prints warnings when present", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||
req.on("end", () => {
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({
|
||
status: "completed", runId: "run-3", rigId: "rig-2",
|
||
stages: [{ stage: "import_rig", status: "ok" }],
|
||
errors: [],
|
||
warnings: ["Transcript capture failed for dev-impl@test-rig: pipe-pane failed", "Startup submission unverified in dev-impl@test-rig: capture unavailable"],
|
||
}));
|
||
} else {
|
||
res.writeHead(404).end();
|
||
}
|
||
});
|
||
});
|
||
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
});
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("warning: Transcript capture failed");
|
||
expect(output).toContain("Startup submission unverified in dev-impl@test-rig");
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
});
|
||
|
||
// PNS-T03: rig up aborts on preflight failure (daemon not running, port in use)
|
||
it("up aborts with preflight error when daemon not running and port in use", async () => {
|
||
// Start a TCP server on a port to trigger port-in-use
|
||
const net = await import("node:net");
|
||
const blockingServer = net.createServer();
|
||
await new Promise<void>((resolve) => blockingServer.listen(0, resolve));
|
||
const blockedPort = (blockingServer.address() as { port: number }).port;
|
||
|
||
// Create deps where daemon is NOT running (triggers auto-start → preflight)
|
||
const stoppedDeps: StatusDeps = {
|
||
lifecycleDeps: {
|
||
...mockLifecycleDeps(),
|
||
exists: vi.fn(() => false), // No daemon.json → stopped
|
||
readFile: vi.fn(() => null),
|
||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||
},
|
||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||
};
|
||
|
||
// Set OPENRIG_PORT to the blocked port so preflight detects collision
|
||
const savedPort = process.env["OPENRIG_PORT"];
|
||
process.env["OPENRIG_PORT"] = String(blockedPort);
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand(stoppedDeps));
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
try {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
} catch { /* commander may throw on exitOverride */ }
|
||
});
|
||
blockingServer.close();
|
||
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
|
||
else delete process.env["OPENRIG_PORT"];
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("port");
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
it("up --plan with no daemon running starts nothing and says how to start one", async () => {
|
||
const spawn = vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never);
|
||
const clientFactory = vi.fn();
|
||
const deps: StatusDeps = {
|
||
// No state file and nothing answering: the daemon is down, as on a fresh instance.
|
||
lifecycleDeps: { ...mockLifecycleDeps(), exists: vi.fn(() => false), readFile: vi.fn(() => null), spawn, fetch: vi.fn(async (url: string) => { throw new Error(`refused:${url}`); }) },
|
||
clientFactory: clientFactory as unknown as StatusDeps["clientFactory"],
|
||
};
|
||
const run = async (args: string[]) => {
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
return captureLogs(async () => { await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml", "--plan", ...args]); });
|
||
};
|
||
|
||
const text = await run([]);
|
||
expect(text.exitCode).toBe(1);
|
||
expect(text.logs.join("\n")).toContain("A preview doesn't start one");
|
||
expect(text.logs.join("\n")).toContain("rig daemon start --no-kernel");
|
||
const json = await run(["--json"]);
|
||
expect(JSON.parse(json.logs.at(-1)!)).toMatchObject({ ok: false, code: "daemon_not_running", plan: true, changed: false });
|
||
expect(spawn).not.toHaveBeenCalled(); // no daemon, so no kernel and no agent turns
|
||
expect(clientFactory).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("auto-start uses resolved daemon port instead of default 7433", async () => {
|
||
const savedPort = process.env["OPENRIG_PORT"];
|
||
process.env["OPENRIG_PORT"] = "7461";
|
||
|
||
let daemonState: DaemonState | null = null;
|
||
let daemonStarted = false;
|
||
let spawnedPort: string | undefined;
|
||
let clientBaseUrl: string | undefined;
|
||
|
||
const deps: StatusDeps = {
|
||
lifecycleDeps: {
|
||
...mockLifecycleDeps(),
|
||
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
|
||
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
|
||
writeFile: vi.fn((p: string, content: string) => {
|
||
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
|
||
}),
|
||
openForAppend: vi.fn(() => 3),
|
||
mkdirp: vi.fn(),
|
||
spawn: vi.fn((cmd, args, opts) => {
|
||
daemonStarted = true;
|
||
spawnedPort = opts.env["OPENRIG_PORT"];
|
||
return Object.assign(new EventEmitter(), { pid: 321, exitCode: null, signalCode: null, unref: vi.fn() }) as never;
|
||
}),
|
||
fetch: vi.fn(async (url: string) => {
|
||
if (!daemonStarted) throw new Error(`refused:${url}`);
|
||
return { ok: url === "http://127.0.0.1:7461/healthz", json: async () => ({ pid: 321, bind: { mode: "explicit", hosts: ["127.0.0.1"], tailscaleDetected: false } }) };
|
||
}),
|
||
},
|
||
clientFactory: (baseUrl) => {
|
||
clientBaseUrl = baseUrl;
|
||
return {
|
||
post: vi.fn(async () => ({
|
||
status: 201,
|
||
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
|
||
})),
|
||
} as unknown as DaemonClient;
|
||
},
|
||
};
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
|
||
const { exitCode } = await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
});
|
||
|
||
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
|
||
else delete process.env["OPENRIG_PORT"];
|
||
|
||
expect(exitCode).toBeUndefined();
|
||
expect(spawnedPort).toBe("7461");
|
||
expect(clientBaseUrl).toBe("http://127.0.0.1:7461");
|
||
});
|
||
|
||
// bug-fix slice auth-bearer-tailscale-trust forward-fix #2: `rig up`
|
||
// auto-start path was the second product launch path materializing
|
||
// the default daemon.host into OPENRIG_HOST. S20 re-grounded the env half:
|
||
// source=default → omit (daemon multi-binds); source=env is the overloaded
|
||
// ROUTING channel and NEVER creates bind intent (r2 NOT-CLEAR at 95150982d:
|
||
// this very suite carried a positive pin REQUIRING the incident behavior).
|
||
describe("auth-bearer-tailscale-trust: up auto-start respects daemon.host source", () => {
|
||
function makeAutoStartDeps(captureSpawn: (env: Record<string, string>) => void): StatusDeps {
|
||
let daemonState: DaemonState | null = null;
|
||
let daemonStarted = false;
|
||
return {
|
||
lifecycleDeps: {
|
||
...mockLifecycleDeps(),
|
||
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
|
||
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
|
||
writeFile: vi.fn((p: string, content: string) => {
|
||
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
|
||
}),
|
||
openForAppend: vi.fn(() => 3),
|
||
mkdirp: vi.fn(),
|
||
spawn: vi.fn((cmd, args, opts) => {
|
||
daemonStarted = true;
|
||
captureSpawn(opts.env as Record<string, string>);
|
||
return Object.assign(new EventEmitter(), { pid: 999, exitCode: null, signalCode: null, unref: vi.fn() }) as never;
|
||
}),
|
||
fetch: vi.fn(async (url: string) => {
|
||
if (!daemonStarted) throw new Error(`refused:${url}`);
|
||
return { ok: url.includes("/healthz"), json: async () => ({ pid: 999, bind: { mode: "explicit", hosts: ["127.0.0.1"], tailscaleDetected: false } }) };
|
||
}),
|
||
},
|
||
clientFactory: () => ({
|
||
post: vi.fn(async () => ({
|
||
status: 201,
|
||
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
|
||
})),
|
||
} as unknown as DaemonClient),
|
||
};
|
||
}
|
||
|
||
it("default (no env, no config) does NOT export OPENRIG_HOST so daemon multi-binds", async () => {
|
||
const savedHost = process.env["OPENRIG_HOST"];
|
||
const savedPort = process.env["OPENRIG_PORT"];
|
||
delete process.env["OPENRIG_HOST"];
|
||
process.env["OPENRIG_PORT"] = "7471";
|
||
|
||
let spawnedEnv: Record<string, string> = {};
|
||
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
|
||
await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
});
|
||
|
||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||
else process.env["OPENRIG_HOST"] = savedHost;
|
||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||
else process.env["OPENRIG_PORT"] = savedPort;
|
||
|
||
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
|
||
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7471");
|
||
});
|
||
|
||
it("S20: env-sourced OPENRIG_HOST creates NO bind intent through up auto-start (routing never crosses)", async () => {
|
||
const { default: net } = await import("node:net");
|
||
const socket = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn(), connect() { queueMicrotask(() => socket.emit("error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" }))); } });
|
||
const socketSpy = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as import("node:net").Socket);
|
||
const savedHost = process.env["OPENRIG_HOST"];
|
||
const savedPort = process.env["OPENRIG_PORT"];
|
||
// Use a distinct value so the discriminator can't pass vacuously:
|
||
// 100.64.55.66 is a tailscale CGNAT address — operator-explicit
|
||
// bind would normally be honored as-is.
|
||
process.env["OPENRIG_HOST"] = "100.64.55.66";
|
||
process.env["OPENRIG_PORT"] = "7472";
|
||
|
||
let spawnedEnv: Record<string, string> = {};
|
||
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
|
||
try { await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
}); } finally { socketSpy.mockRestore(); }
|
||
|
||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||
else process.env["OPENRIG_HOST"] = savedHost;
|
||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||
else process.env["OPENRIG_PORT"] = savedPort;
|
||
|
||
// S20 (r2 blocker repair): env-sourced OPENRIG_HOST is ROUTING state — the
|
||
// auto-start must create NO bind intent from it and the routing var must not
|
||
// cross into the daemon env (the incident: injected 127.0.0.1 took single-bind
|
||
// and dropped the Tailscale listener).
|
||
expect(spawnedEnv["OPENRIG_BIND_HOST"]).toBeUndefined();
|
||
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
|
||
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7472");
|
||
});
|
||
});
|
||
|
||
// QUARANTINED (D12 base-health) — NOT a test bug and NOT test-shimmed: this
|
||
// asserts fast-fail on an UNSTARTABLE daemon (fatal better-sqlite3 spawn log),
|
||
// but startDaemon's healthz poll is a hard-coded 80 × 250ms setTimeout (~20s,
|
||
// non-injectable, with no early-bail when the spawn log already shows a fatal
|
||
// error), so `up` hangs past the 15s budget. Routed as a product-defect finding
|
||
// (D12-F1: slow-fail auto-start + non-injectable poll delay). Skipped — with the
|
||
// named cause — until that fix lands, so the family stops polluting fold gates.
|
||
it.skip("up surfaces the real daemon auto-start failure instead of a generic hint", async () => {
|
||
const savedPort = process.env["OPENRIG_PORT"];
|
||
process.env["OPENRIG_PORT"] = "7463";
|
||
const deps: StatusDeps = {
|
||
lifecycleDeps: {
|
||
...mockLifecycleDeps(),
|
||
exists: vi.fn(() => false),
|
||
readFile: vi.fn((p: string) => {
|
||
if (p === LOG_FILE) {
|
||
return [
|
||
"Error: The module '/tmp/better_sqlite3.node'",
|
||
"was compiled against a different Node.js version using",
|
||
"NODE_MODULE_VERSION 127. This version of Node.js requires",
|
||
"NODE_MODULE_VERSION 141.",
|
||
"code: 'ERR_DLOPEN_FAILED'",
|
||
].join("\n");
|
||
}
|
||
return null;
|
||
}),
|
||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||
},
|
||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||
};
|
||
|
||
const prog = new Command();
|
||
prog.exitOverride();
|
||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||
});
|
||
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
|
||
else delete process.env["OPENRIG_PORT"];
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("better-sqlite3");
|
||
expect(output).toContain("Node");
|
||
expect(output).not.toContain("Failed to auto-start daemon. Start manually with: rig daemon start");
|
||
expect(exitCode).toBe(2);
|
||
}, 15000);
|
||
|
||
it("up with library name matching existing rig shows ambiguity error", async () => {
|
||
// Mock server that has both a library spec and an existing rig named "my-rig"
|
||
let startupRequests = 0;
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
const url = decodeURIComponent(req.url ?? "");
|
||
if (url === "/api/up") startupRequests++;
|
||
if (url.startsWith("/api/specs/library")) {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "lib1", name: "alpha", sourcePath: "/specs/alpha.yaml" }]));
|
||
} else if (url === "/api/rigs/summary") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1 }]));
|
||
} else {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({}));
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "alpha"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
expect(logs.join("\n")).toContain("ambiguous");
|
||
expect(logs.join("\n")).toContain("existing rig restore target");
|
||
expect(logs.join("\n")).toContain("/specs/alpha.yaml");
|
||
expect(logs.join("\n")).toContain("rig up alpha --existing");
|
||
expect(logs.join("\n")).not.toContain("rename or remove");
|
||
expect(startupRequests).toBe(0);
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
it.each([
|
||
["a running rig", [{ id: "r1", name: "alpha", nodeCount: 1 }], []],
|
||
["an archived rig", [], [{ id: "r0", name: "alpha" }]],
|
||
])("up <library name> --name launches the library spec when the name also matches %s", async (_label, active, archived) => {
|
||
const posts: Array<Record<string, unknown>> = [];
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
const url = decodeURIComponent(req.url ?? "");
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
res.writeHead(url === "/api/up" ? 201 : 200, { "Content-Type": "application/json" });
|
||
if (url === "/api/up") {
|
||
posts.push(JSON.parse(body));
|
||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||
} else if (url.startsWith("/api/specs/library")) {
|
||
res.end(JSON.stringify([{ id: "lib1", name: "alpha", sourcePath: "/specs/alpha.yaml" }]));
|
||
} else if (url === "/api/rigs/summary?archived=only") {
|
||
res.end(JSON.stringify(archived));
|
||
} else if (url === "/api/rigs/summary") {
|
||
res.end(JSON.stringify(active));
|
||
} else {
|
||
res.end(JSON.stringify({}));
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--name", "beta"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
expect(logs.join("\n")).not.toContain("ambiguous");
|
||
expect(logs.join("\n")).not.toContain("archived");
|
||
expect(posts).toHaveLength(1);
|
||
expect(posts[0]).toMatchObject({ sourceRef: "/specs/alpha.yaml", rigName: "beta" });
|
||
expect(exitCode ?? 0).toBe(0);
|
||
});
|
||
|
||
it.each([
|
||
["fresh alias", [], [], false, 0, "first-project"],
|
||
["existing starter", [{ id: "new-rig", name: "starter" }], [], false, 1, "first-project"],
|
||
["existing old name", [{ id: "old-rig", name: "first-project" }], [], false, 1, "first-project"],
|
||
["archived starter", [], [{ id: "archived-new", name: "starter" }], false, 1, "first-project"],
|
||
["archived old name", [], [{ id: "archived-old", name: "first-project" }], false, 1, "first-project"],
|
||
["explicit old restore", [{ id: "old-rig", name: "first-project" }], [], true, 0, "first-project"],
|
||
["explicit library ID", [{ id: "new-rig", name: "starter" }], [], false, 0, "starter-spec"],
|
||
] as const)("first-project routing: %s", async (_label, active, archived, existing, exit, input) => {
|
||
const originalListeners = server.listeners("request").slice();
|
||
server.removeAllListeners("request");
|
||
const posts: Array<Record<string, unknown>> = [];
|
||
const requests: string[] = [];
|
||
server.on("request", async (req, res) => {
|
||
requests.push(req.url ?? "");
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
if (req.url === "/api/rigs/summary") res.end(JSON.stringify(active));
|
||
else if (req.url === "/api/rigs/summary?archived=only") res.end(JSON.stringify(archived));
|
||
else if (req.url?.startsWith("/api/specs/library")) res.end(JSON.stringify([{
|
||
id: "starter-spec", name: "starter", kind: "rig", sourceType: "builtin", sourcePath: "/builtin/starter/rig.yaml",
|
||
}]));
|
||
else if (req.url === "/api/up") {
|
||
posts.push(JSON.parse(body));
|
||
res.end(JSON.stringify({ status: "planned", runId: "alias-plan", stages: [], errors: [], warnings: [] }));
|
||
} else res.end("{}");
|
||
});
|
||
try {
|
||
const result = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", input, "--plan", "--json", ...(existing ? ["--existing"] : [])]);
|
||
});
|
||
expect(result.exitCode ?? 0).toBe(exit);
|
||
if (exit) {
|
||
expect(posts).toHaveLength(0);
|
||
if (archived.length) expect(result.logs.join("\n")).toContain(`rig unarchive ${archived[0]!.id}`);
|
||
else expect(result.logs.join("\n")).toContain(`rig up ${active[0]!.name} --existing`);
|
||
} else {
|
||
expect(posts).toHaveLength(1);
|
||
expect(posts[0]!.sourceRef).toBe(existing ? "first-project" : "/builtin/starter/rig.yaml");
|
||
if (existing) expect(requests.some((url) => url.startsWith("/api/specs/library"))).toBe(false);
|
||
else expect(posts[0]!.cwdOverride).toBe(process.cwd());
|
||
expect(result.logs.filter((line) => line.startsWith("{")).map((line) => JSON.parse(line))).toHaveLength(1);
|
||
}
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const listener of originalListeners) server.on("request", listener as (req: http.IncomingMessage, res: http.ServerResponse) => void);
|
||
}
|
||
});
|
||
|
||
it("up resolves a same-name rig and workflow to the rig library entry", async () => {
|
||
const origListeners = server.listeners("request");
|
||
let lastBody: Record<string, unknown> = {};
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
const url = decodeURIComponent(req.url ?? "");
|
||
if (url.startsWith("/api/specs/library?kind=rig")) {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
|
||
} else if (url === "/api/rigs/summary") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "planned", runId: "r", stages: [], errors: [] }));
|
||
} else {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${url}` }));
|
||
}
|
||
});
|
||
|
||
const { exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "conveyor", "--plan"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
expect(lastBody.sourceRef).toBe("/specs/rigs/conveyor/rig.yaml");
|
||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||
expect(exitCode).toBeUndefined();
|
||
});
|
||
|
||
it.each([false, true])("up --existing bypasses library-name ambiguity with plan=%s", async (plan) => {
|
||
const origListeners = server.listeners("request");
|
||
let lastBody: Record<string, unknown> = {};
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1, lifecycleState: "recoverable" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "restored", rigId: "r1", rigName: "alpha", rigResult: "restored", nodes: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(500, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${req.url}` }));
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing", ...(plan ? ["--plan"] : [])]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
expect(lastBody.sourceRef).toBe("alpha");
|
||
expect(lastBody.plan).toBe(plan);
|
||
expect(logs.join("\n")).toContain('Recovering rig "alpha" from latest snapshot or current DB state');
|
||
expect(exitCode).toBeUndefined();
|
||
});
|
||
|
||
// L2 wording divergence: rig name in recoverable state -> "Recovering ..."; stopped -> "Turning on ..."
|
||
it("up <rig-name> prints 'Recovering ... from latest snapshot' when lifecycleState=recoverable", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
// No library match — falls through to existing-rig path
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-rec", name: "stale-velocity", nodeCount: 4, lifecycleState: "recoverable" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "restored", rigId: "r-rec", rigName: "stale-velocity", rigResult: "restored", nodes: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "stale-velocity"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot or current DB state');
|
||
expect(output).not.toContain('Turning on rig "stale-velocity"');
|
||
expect(exitCode).toBeUndefined(); // 0
|
||
});
|
||
|
||
it("up <rig-name> prints 'Turning on ...' when lifecycleState=stopped (no usable snapshot)", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-stop", name: "fresh-rig", nodeCount: 1, lifecycleState: "stopped" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", rigId: "r-stop", stages: [], errors: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "fresh-rig"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain('Turning on rig "fresh-rig"');
|
||
expect(output).not.toContain('Recovering rig "fresh-rig"');
|
||
expect(exitCode).toBeUndefined();
|
||
});
|
||
|
||
it("up <rig-name> prints no recovery wording when lifecycleState=running (already up)", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-run", name: "live-rig", nodeCount: 1, lifecycleState: "running" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", rigId: "r-run", stages: [], errors: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "live-rig"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).not.toContain("Recovering rig");
|
||
expect(output).not.toContain("Turning on rig");
|
||
expect(exitCode).toBeUndefined();
|
||
});
|
||
|
||
it("up --json suppresses the wording prefix even for recoverable rigs (machine output stays clean)", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-rec", name: "json-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "restored", rigId: "r-rec", rigName: "json-rig", rigResult: "restored", nodes: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "json-rig", "--json"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
// The body must be valid JSON (no preceding "Recovering ..." text).
|
||
const joined = logs.join("");
|
||
expect(() => JSON.parse(joined)).not.toThrow();
|
||
});
|
||
|
||
// OPR.0.3.3.19 AC-7: a name matching ONLY an archived rig is refused with an
|
||
// honest error pointing at `rig unarchive` - never a silent restore.
|
||
it("up <archived-name> refuses with an honest unarchive error and never posts /api/up", async () => {
|
||
const origListeners = server.listeners("request");
|
||
let upHit = false;
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
// No ACTIVE rig of this name (default summary excludes archived).
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||
// The name matches an archived rig.
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
upHit = true;
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed", rigId: "r-arc", stages: [], errors: [], warnings: [] }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain('Rig "tidy-me" is archived');
|
||
// Remediation must name the rig ID (rig unarchive resolves by id, not name).
|
||
expect(output).toContain("rig unarchive r-arc");
|
||
expect(output).not.toContain("rig unarchive tidy-me");
|
||
expect(upHit).toBe(false); // no silent restore
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
// OPR.0.3.3.19 AC-7 (json): same refusal, machine-readable.
|
||
it("up <archived-name> --json emits a rig_archived error object and exits 1", async () => {
|
||
const origListeners = server.listeners("request");
|
||
let upHit = false;
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
upHit = true;
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed" }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me", "--json"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const parsed = JSON.parse(logs.join(""));
|
||
expect(parsed.error).toBe("rig_archived");
|
||
// Action targets the rig ID (rig unarchive resolves by id, not name).
|
||
expect(parsed.action).toBe("rig unarchive r-arc");
|
||
expect(parsed.archivedRigIds).toEqual(["r-arc"]);
|
||
expect(upHit).toBe(false);
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
// OPR.0.3.3.19 AC-7 (ambiguous): a name shared by multiple archived rigs must
|
||
// surface the id list, not guess a single (wrong) target.
|
||
it("up <archived-name> with multiple archived id matches lists each id, never posts /api/up", async () => {
|
||
const origListeners = server.listeners("request");
|
||
let upHit = false;
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([
|
||
{ id: "r-arc-1", name: "dupe", nodeCount: 1 },
|
||
{ id: "r-arc-2", name: "dupe", nodeCount: 1 },
|
||
]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
upHit = true;
|
||
res.writeHead(201, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "completed" }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "dupe"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("rig unarchive r-arc-1");
|
||
expect(output).toContain("rig unarchive r-arc-2");
|
||
expect(upHit).toBe(false);
|
||
expect(exitCode).toBe(1);
|
||
});
|
||
|
||
// L3b: manual-fallback note printed when restore came from a non-auto-pre-down snapshot.
|
||
it("L3b: prints manual-fallback note when daemon restored from a manual snapshot", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-l3b", name: "manual-only-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "restored", rigId: "r-l3b", rigName: "manual-only-rig", rigResult: "fully_restored", nodes: [], warnings: [], snapshotKind: "manual" }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs, exitCode } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "manual-only-rig"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).toContain("Restoring from manual snapshot (kind=manual); no auto-pre-down snapshot available.");
|
||
expect(output).toContain('Rig "manual-only-rig" restored');
|
||
expect(exitCode).toBeUndefined();
|
||
});
|
||
|
||
it("L3b: does NOT print manual-fallback note when restore came from auto-pre-down", async () => {
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/specs/library" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([]));
|
||
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify([{ id: "r-l3b-auto", name: "auto-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
|
||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({ status: "restored", rigId: "r-l3b-auto", rigName: "auto-rig", rigResult: "fully_restored", nodes: [], warnings: [], snapshotKind: "auto-pre-down" }));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", "auto-rig"]);
|
||
});
|
||
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
|
||
const output = logs.join("\n");
|
||
expect(output).not.toContain("Restoring from manual snapshot");
|
||
expect(output).toContain('Rig "auto-rig" restored');
|
||
});
|
||
|
||
// Agent Starter v1 vertical M2 R2 — CLI plan smoke proves resolved
|
||
// starter contents reach plan output (Path A from the M2 R2 dispatch
|
||
// packet). The daemon's plan-mode response carries a `resolve_starter`
|
||
// stage with the resolved starter ResolvedStartupFile shape; the CLI's
|
||
// --json mode renders the full daemon response, so the starter content
|
||
// surfaces verbatim in plan output. M2 R1 only asserted argument
|
||
// forwarding, which proved nothing about plan visibility of starter
|
||
// resolution.
|
||
it("M2 R2: rig up --plan --json against a starter_ref fixture spec surfaces resolved starter contents", async () => {
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const fs = await import("node:fs");
|
||
const os = await import("node:os");
|
||
const path = await import("node:path");
|
||
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-up-starter-r2-"));
|
||
const specPath = path.join(tmpDir, "starter-fixture.yaml");
|
||
fs.writeFileSync(specPath, `version: "0.2"
|
||
name: starter-cli-smoke
|
||
pods:
|
||
- id: dev
|
||
label: Development
|
||
members:
|
||
- id: impl
|
||
agent_ref: local:agents/impl
|
||
profile: default
|
||
runtime: claude-code
|
||
cwd: .
|
||
starter_ref:
|
||
name: openrig-builder-base--claude-code
|
||
edges: []
|
||
edges: []
|
||
`, "utf-8");
|
||
|
||
let lastBody: Record<string, unknown> = {};
|
||
const origListeners = server.listeners("request");
|
||
server.removeAllListeners("request");
|
||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||
let body = "";
|
||
for await (const chunk of req) body += chunk;
|
||
if (req.url === "/api/up" && req.method === "POST") {
|
||
lastBody = JSON.parse(body);
|
||
// Plan-mode response that carries the resolved starter. The CLI's
|
||
// --json branch (commands/up.ts:175-181) prints the full response
|
||
// verbatim — so the starter content reaches operator output as
|
||
// structured data they can inspect or pipe into other tools.
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(JSON.stringify({
|
||
status: "planned",
|
||
runId: "starter-run",
|
||
stages: [
|
||
{ stage: "resolve_spec", status: "ok" },
|
||
{
|
||
stage: "resolve_starter",
|
||
status: "ok",
|
||
detail: {
|
||
memberId: "dev.impl",
|
||
starterRef: "openrig-builder-base--claude-code",
|
||
starterContent: [
|
||
{
|
||
path: "openrig-builder-base--claude-code.yaml",
|
||
ownerRoot: "/fixture/registry",
|
||
deliveryHint: "guidance_merge",
|
||
appliesOn: ["fresh_start"],
|
||
required: true,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
errors: [],
|
||
warnings: [],
|
||
}));
|
||
} else {
|
||
res.writeHead(404);
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
try {
|
||
const { logs } = await captureLogs(async () => {
|
||
await makeCmd().parseAsync(["node", "rig", "up", specPath, "--plan", "--json"]);
|
||
});
|
||
const output = logs.join("\n");
|
||
// The CLI's --json mode emits a single JSON.stringify(res.data) line.
|
||
// Parse it and verify the starter content reached plan output.
|
||
const parsed = JSON.parse(output) as {
|
||
status: string;
|
||
stages: Array<{ stage: string; status: string; detail?: Record<string, unknown> }>;
|
||
};
|
||
expect(parsed.status).toBe("planned");
|
||
const resolveStarter = parsed.stages.find((s) => s.stage === "resolve_starter");
|
||
expect(resolveStarter, "expected resolve_starter stage to surface in plan output").toBeDefined();
|
||
expect(resolveStarter!.status).toBe("ok");
|
||
const starterContent = (resolveStarter!.detail as { starterContent?: Array<Record<string, unknown>> })?.starterContent;
|
||
expect(starterContent, "expected resolved starter contents in plan output").toBeDefined();
|
||
expect(Array.isArray(starterContent)).toBe(true);
|
||
expect(starterContent!.length).toBeGreaterThan(0);
|
||
expect(starterContent![0]!["path"]).toBe("openrig-builder-base--claude-code.yaml");
|
||
expect(starterContent![0]!["deliveryHint"]).toBe("guidance_merge");
|
||
expect(starterContent![0]!["appliesOn"]).toEqual(["fresh_start"]);
|
||
|
||
// Belt-and-suspenders: argument forwarding still asserted so the
|
||
// smoke also catches CLI request-shape regressions.
|
||
expect(lastBody.sourceRef).toBe(specPath);
|
||
expect(lastBody.plan).toBe(true);
|
||
} finally {
|
||
server.removeAllListeners("request");
|
||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|