fix conveyor release cli paths

This commit is contained in:
mvschwarz committed 2026-05-10 03:25:33 -07:00
1 parent 2f561633ec
commit b74b1c5d48
10 files changed
+161 -23

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ Every agent runs in a tmux session you can attach to, inspect, and work with dir
OpenRig ships with a conveyor starter rig for learning workflow handoff:
```bash
rig specs preview conveyor
rig specs preview conveyor --kind rig
```
```
+8 -3
View File
@@ -27,7 +27,8 @@ export function importCommand(depsOverride?: ImportDeps): Command {
.option("--preflight", "Run preflight checks")
.option("--target-rig <rigId>", "Target existing rig for additive materialization")
.option("--rig-root <root>", "Root directory for pod-aware resolution")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string }) => {
.option("--cwd <path>", "Override launch/materialization working directory for all members")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
const deps = getDeps();
// Read local file first (before daemon check — fail fast on missing file)
@@ -59,7 +60,11 @@ export function importCommand(depsOverride?: ImportDeps): Command {
const rigRoot = podAware
? (opts.rigRoot ? nodePath.resolve(opts.rigRoot) : nodePath.dirname(nodePath.resolve(filePath)))
: undefined;
const extraHeaders = rigRoot ? { "X-Rig-Root": rigRoot } : undefined;
const cwdOverride = opts.cwd ? nodePath.resolve(opts.cwd) : undefined;
const extraHeaders = {
...(rigRoot ? { "X-Rig-Root": rigRoot } : {}),
...(cwdOverride ? { "X-Cwd-Override": cwdOverride } : {}),
};
if (opts.instantiate && opts.materializeOnly) {
console.error("Choose either --instantiate or --materialize-only, not both.");
@@ -97,7 +102,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
return;
}
const headers = {
...(extraHeaders ?? {}),
...extraHeaders,
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
};
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
+15 -10
View File
@@ -20,7 +20,7 @@ import type { StatusDeps } from "./status.js";
interface LibraryEntry {
id: string;
kind: string;
kind: "rig" | "agent" | "workflow";
name: string;
version: string;
sourceType: string;
@@ -145,8 +145,9 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: LibraryEntry["kind"] },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>("/api/specs/library");
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
// Try exact ID match first
@@ -158,14 +159,16 @@ export async function resolveLibrarySpec(
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
const candidates = byName.map((e) => ` ${e.id} — ${e.sourcePath}`).join("\n");
const candidates = byName.map((e) => ` ${e.id} (${e.kind}) — ${e.sourcePath}`).join("\n");
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec name '${nameOrId}' is ambiguous — ${byName.length} entries match.\nUse the ID instead:\n${candidates}`
`Spec name '${nameOrId}' is ambiguous — ${byName.length}${scope} entries match.\nUse the ID instead:\n${candidates}`
);
}
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
`Spec '${nameOrId}' not found in${scope} library. Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.`
);
}
@@ -195,7 +198,7 @@ Examples:
// specs ls
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
@@ -227,11 +230,12 @@ Examples:
cmd.command("show")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -254,11 +258,12 @@ Examples:
cmd.command("preview")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
if (opts.json) {
+1 -1
View File
@@ -123,7 +123,7 @@ Examples:
if (isRigName && !opts.existing) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
const entry = await resolveLibrarySpec(client, source);
const entry = await resolveLibrarySpec(client, source, { kind: "rig" });
// Library match found — check for existing-rig collision
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
const rigSummaries = await fetchRigSummaries();
+22
View File
@@ -124,6 +124,7 @@ function createMockDaemon() {
if (req.method === "POST" && url.pathname === "/api/rigs/import") {
capturedImportHeaders = {
"x-rig-root": req.headers["x-rig-root"],
"x-cwd-override": req.headers["x-cwd-override"],
};
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
@@ -144,6 +145,7 @@ function createMockDaemon() {
capturedImportHeaders = {
"x-rig-root": req.headers["x-rig-root"],
"x-target-rig-id": req.headers["x-target-rig-id"],
"x-cwd-override": req.headers["x-cwd-override"],
};
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
@@ -298,6 +300,16 @@ describe("rig export + import", () => {
expect(capturedImportHeaders["x-target-rig-id"]).toBe("rig-123");
});
it("import --materialize-only with --cwd forwards absolute cwd override", async () => {
capturedImportHeaders = {};
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--cwd", "relative/project", "--rig-root", "/tmp"]));
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
});
// Test 8: import --instantiate with preflight fail (409)
it("import --instantiate: preflight conflict (409)", async () => {
const deps = importDeps("CONFLICT");
@@ -423,6 +435,16 @@ describe("rig export + import", () => {
expect(capturedImportHeaders["x-rig-root"]).toMatch(/\/my\/project/);
});
it("import --instantiate with --cwd forwards absolute cwd override", async () => {
capturedImportHeaders = {};
const deps = importDeps("schema_version: 1\nname: test\npods:\n - name: pod-a\n");
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate", "--cwd", "relative/project", "--rig-root", "/my/project"]));
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
});
// NS-T14: import --instantiate handoff includes attach command
it("import --instantiate success shows attach command", async () => {
const deps: ImportDeps = {
+39 -3
View File
@@ -146,18 +146,46 @@ describe("Specs CLI", () => {
expect(exitCode).toBe(1);
});
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
res.writeHead(404).end();
}
});
await new Promise<void>((resolve) => { dupServer.listen(0, resolve); });
const dupPort = (dupServer.address() as { port: number }).port;
const prog = new Command();
prog.exitOverride();
prog.addCommand(specsCommand(runningDeps(dupPort)));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "specs", "show", "conveyor", "--kind", "rig"]);
});
dupServer.close();
const output = logs.join("\n");
expect(output).toContain("Kind: rig");
expect(output).toContain("/builtin/review-rig.yaml");
expect(exitCode).toBeUndefined();
});
it("specs show not-found error points people back to managed apps in the library", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);
});
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, and managed apps.");
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.");
expect(exitCode).toBe(1);
});
it("specs preview --json returns structured review", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--json"]);
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--kind", "rig", "--json"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.kind).toBe("rig");
@@ -177,12 +205,14 @@ describe("Specs CLI", () => {
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
// Use temp dir as HOME to avoid polluting real ~/.openrig/specs/
// Use temp OpenRig home to avoid polluting real ~/.openrig/specs/
const tmpDir = mkdtempSync(join(tmpdir(), "specs-add-"));
const specPath = join(tmpDir, "test-spec.yaml");
writeFileSync(specPath, 'name: test-spec\nversion: "0.2"\npods: []\nedges: []\n');
const savedHome = process.env["HOME"];
const savedOpenRigHome = process.env["OPENRIG_HOME"];
process.env["HOME"] = tmpDir;
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
const addServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
@@ -220,6 +250,8 @@ describe("Specs CLI", () => {
expect(existsSync(join(tmpDir, ".openrig", "specs", "test-spec.yaml"))).toBe(true);
process.env["HOME"] = savedHome;
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
else delete process.env["OPENRIG_HOME"];
rmSync(tmpDir, { recursive: true, force: true });
const output = logs.join("\n");
@@ -275,7 +307,9 @@ describe("Specs CLI", () => {
].join("\n"));
const savedHome = process.env["HOME"];
const savedOpenRigHome = process.env["OPENRIG_HOME"];
process.env["HOME"] = tmpDir;
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
const addServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
@@ -320,6 +354,8 @@ describe("Specs CLI", () => {
} finally {
addServer.close();
process.env["HOME"] = savedHome;
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
else delete process.env["OPENRIG_HOME"];
rmSync(tmpDir, { recursive: true, force: true });
}
});
+36
View File
@@ -690,6 +690,42 @@ describe("Up CLI", () => {
expect(exitCode).toBe(1);
});
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 === "/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("up --existing bypasses library-name ambiguity and posts the rig name", async () => {
const origListeners = server.listeners("request");
let lastBody: Record<string, unknown> = {};
@@ -365,7 +365,7 @@ export class PodRigInstantiator {
async materialize(
rigSpecYaml: string,
rigRoot: string,
opts?: { targetRigId?: string; suppressSummaryEvent?: boolean },
opts?: { targetRigId?: string; suppressSummaryEvent?: boolean; cwdOverride?: string },
): Promise<MaterializeOutcome> {
let raw: unknown;
try {
@@ -390,6 +390,7 @@ export class PodRigInstantiator {
const preflight = rigPreflight({
rigSpecYaml,
rigRoot,
cwdOverride: opts?.cwdOverride,
fsOps: this.deps.fsOps,
rigNameOverride: targetRig?.rig.name,
externalQualifiedIds: targetRig?.nodes.map((node) => node.logicalId),
@@ -452,11 +453,12 @@ export class PodRigInstantiator {
for (const member of pod.members) {
const qualifiedId = `${pod.id}.${member.id}`;
const effectiveCwd = resolveLaunchCwd(member.cwd, rigRoot, opts?.cwdOverride);
const node = this.deps.rigRepo.addNode(materializedRigId, qualifiedId, {
runtime: member.runtime,
model: member.model,
codexConfigProfile: member.codexConfigProfile,
cwd: member.cwd,
cwd: effectiveCwd,
restorePolicy: member.restorePolicy,
podId: podRecord.id,
agentRef: member.agentRef,
+6 -3
View File
@@ -80,8 +80,9 @@ rigspecImportRoutes.post("/", async (c) => {
if (isPodAware) {
const rigRoot = c.req.header("X-Rig-Root");
if (!rigRoot) return c.json({ error: "X-Rig-Root header required for pod-aware specs", code: "missing_rig_root" }, 400);
const cwdOverride = c.req.header("X-Cwd-Override") ?? undefined;
const outcome = await podInstantiator.instantiate(body, rigRoot);
const outcome = await podInstantiator.instantiate(body, rigRoot, { cwdOverride });
if (!outcome.ok) {
const status = outcome.code === "validation_failed" ? 400
: outcome.code === "preflight_failed" ? 409
@@ -133,7 +134,8 @@ rigspecImportRoutes.post("/materialize", async (c) => {
if (!rigRoot) return c.json({ error: "X-Rig-Root header required for pod-aware specs", code: "missing_rig_root" }, 400);
const targetRigId = c.req.header("X-Target-Rig-Id") ?? undefined;
const outcome = await podInstantiator.materialize(body, rigRoot, { targetRigId });
const cwdOverride = c.req.header("X-Cwd-Override") ?? undefined;
const outcome = await podInstantiator.materialize(body, rigRoot, { targetRigId, cwdOverride });
if (!outcome.ok) {
const status = outcome.code === "validation_failed" ? 400
: outcome.code === "preflight_failed" ? 409
@@ -181,8 +183,9 @@ rigspecImportRoutes.post("/preflight", async (c) => {
if (isPodAware) {
const rigRoot = c.req.header("X-Rig-Root");
if (!rigRoot) return c.json({ ready: false, errors: ["X-Rig-Root header required for pod-aware specs"], warnings: [] }, 400);
const cwdOverride = c.req.header("X-Cwd-Override") ?? undefined;
const fsOps = { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) };
const result = rigPreflight({ rigSpecYaml: body, rigRoot, fsOps });
const result = rigPreflight({ rigSpecYaml: body, rigRoot, cwdOverride, fsOps });
return c.json(result);
}
@@ -320,6 +320,22 @@ pods:
edges: []
`;
const MATERIALIZE_RELATIVE_CWD_YAML = `
version: "0.2"
name: cwd-override-topology
pods:
- id: research
label: Research
members:
- id: scout
agent_ref: "builtin:terminal"
profile: none
runtime: terminal
cwd: /openrig-install-should-not-be-used
edges: []
edges: []
`;
const MATERIALIZE_FRAGMENT_YAML = `
version: "0.2"
name: research-fragment
@@ -440,6 +456,19 @@ describe("Rigspec import routes (pod-aware dual-stack)", () => {
expect(body.nodes).toEqual([{ logicalId: "research.scout", status: "materialized" }]);
});
it("POST /api/rigs/import/materialize honors X-Cwd-Override", async () => {
const setup = createTestApp(db);
const res = await setup.app.request("/api/rigs/import/materialize", {
method: "POST",
headers: { "Content-Type": "text/plain", "X-Rig-Root": "/tmp", "X-Cwd-Override": "/tmp" },
body: MATERIALIZE_RELATIVE_CWD_YAML,
});
expect(res.status).toBe(201);
const body = await res.json();
const rig = setup.rigRepo.getRig(body.rigId);
expect(rig?.nodes.find((node) => node.logicalId === "research.scout")?.cwd).toBe("/tmp");
});
it("POST /api/rigs/import/materialize can target an existing rig", async () => {
const setup = createTestApp(db);
const rig = setup.rigRepo.createRig("host-rig");