Skip to content

Commit 93a4199

Browse files
committed
fix cli argv parsing and sdk parity
1 parent 8cb123b commit 93a4199

27 files changed

Lines changed: 980 additions & 128 deletions

cli-argv.test.mjs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* Regression tests for CLI argv parsing.
3+
*
4+
* These stay separate from cli-e2e.test.mjs so parser failures are fast and
5+
* focused: no command should reach the network when argv itself is invalid.
6+
*/
7+
8+
import { describe, it, before, after, beforeEach } from "node:test";
9+
import assert from "node:assert/strict";
10+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11+
import { join } from "node:path";
12+
import { tmpdir } from "node:os";
13+
14+
const tempDir = mkdtempSync(join(tmpdir(), "run402-argv-"));
15+
const API = "https://test-api.run402.com";
16+
process.env.RUN402_CONFIG_DIR = tempDir;
17+
process.env.RUN402_API_BASE = API;
18+
19+
const originalFetch = globalThis.fetch;
20+
const originalLog = console.log;
21+
const originalError = console.error;
22+
const originalExit = process.exit;
23+
let stdout = [];
24+
let stderr = [];
25+
let calls = [];
26+
27+
function json(data, status = 200) {
28+
return new Response(JSON.stringify(data), {
29+
status,
30+
headers: { "Content-Type": "application/json" },
31+
});
32+
}
33+
34+
function requestInfo(input, init) {
35+
const url = typeof input === "string" ? input : (input instanceof Request ? input.url : String(input));
36+
const method = (init?.method || (input instanceof Request ? input.method : "GET") || "GET").toUpperCase();
37+
const path = url.startsWith(API) ? url.slice(API.length) : url;
38+
return { url, method, path, init };
39+
}
40+
41+
function mockFetch(input, init) {
42+
const info = requestInfo(input, init);
43+
calls.push(info);
44+
const pathNoQuery = info.path.split("?")[0];
45+
46+
if (pathNoQuery === "/storage/v1/blobs" && info.method === "GET") {
47+
return Promise.resolve(json({ blobs: [{ key: "file.txt" }] }));
48+
}
49+
if (/\/functions\/hello\/logs$/.test(pathNoQuery) && info.method === "GET") {
50+
return Promise.resolve(json({ logs: [{ timestamp: "2026-05-01T00:00:00Z", message: "ok" }] }));
51+
}
52+
if (/\/functions\/hello$/.test(pathNoQuery) && info.method === "PATCH") {
53+
return Promise.resolve(json({ name: "hello", status: "updated" }));
54+
}
55+
56+
return Promise.resolve(json({ ok: true }));
57+
}
58+
59+
function captureStart() {
60+
stdout = [];
61+
stderr = [];
62+
console.log = (...args) => stdout.push(args.map(String).join(" "));
63+
console.error = (...args) => stderr.push(args.map(String).join(" "));
64+
}
65+
66+
function captureStop() {
67+
console.log = originalLog;
68+
console.error = originalError;
69+
}
70+
71+
function stderrJson() {
72+
const line = stderr.find((s) => s.trim().startsWith("{"));
73+
assert.ok(line, `expected JSON stderr, got: ${stderr.join("\n")}`);
74+
return JSON.parse(line);
75+
}
76+
77+
async function expectExit1(fn) {
78+
let threw = null;
79+
captureStart();
80+
try {
81+
await fn();
82+
} catch (err) {
83+
threw = err;
84+
} finally {
85+
captureStop();
86+
}
87+
assert.equal(threw?.message, "process.exit(1)");
88+
return stderrJson();
89+
}
90+
91+
before(async () => {
92+
globalThis.fetch = mockFetch;
93+
process.exit = (code) => { throw new Error(`process.exit(${code})`); };
94+
const { saveProject, setActiveProjectId } = await import("./cli/core-dist/keystore.js");
95+
saveProject("prj_test123", {
96+
anon_key: "anon_test_key",
97+
service_key: "svc_test_key",
98+
});
99+
setActiveProjectId("prj_test123");
100+
});
101+
102+
after(() => {
103+
globalThis.fetch = originalFetch;
104+
console.log = originalLog;
105+
console.error = originalError;
106+
process.exit = originalExit;
107+
delete process.env.RUN402_CONFIG_DIR;
108+
delete process.env.RUN402_API_BASE;
109+
rmSync(tempDir, { recursive: true, force: true });
110+
});
111+
112+
beforeEach(() => {
113+
calls = [];
114+
captureStop();
115+
});
116+
117+
describe("unknown flags", () => {
118+
it("status rejects unknown flags before doing any work (GH-190)", async () => {
119+
const { run } = await import("./cli/lib/status.mjs");
120+
const err = await expectExit1(() => run(["--unknownflag"]));
121+
122+
assert.equal(err.code, "UNKNOWN_FLAG");
123+
assert.equal(err.details.flag, "--unknownflag");
124+
assert.equal(calls.length, 0, "invalid argv must not hit the network");
125+
});
126+
127+
it("functions logs rejects unknown flags before fetching logs (GH-190)", async () => {
128+
const { run } = await import("./cli/lib/functions.mjs");
129+
const err = await expectExit1(() =>
130+
run("logs", ["prj_test123", "hello", "--no-such-flag", "value"]));
131+
132+
assert.equal(err.code, "UNKNOWN_FLAG");
133+
assert.equal(err.details.flag, "--no-such-flag");
134+
assert.equal(calls.length, 0, "invalid argv must not hit the network");
135+
});
136+
});
137+
138+
describe("--flag=value", () => {
139+
it("blob ls accepts equals-form flags (GH-189)", async () => {
140+
const { run } = await import("./cli/lib/blob.mjs");
141+
captureStart();
142+
await run("ls", ["--project=prj_test123", "--limit=500"]);
143+
captureStop();
144+
145+
const call = calls.find((c) => c.path.startsWith("/storage/v1/blobs?"));
146+
assert.ok(call, `expected blob list request, got ${JSON.stringify(calls)}`);
147+
assert.match(call.url, /limit=500/);
148+
assert.ok(stdout.join("\n").includes("file.txt"));
149+
});
150+
151+
it("functions logs accepts equals-form numeric flags (GH-189)", async () => {
152+
const { run } = await import("./cli/lib/functions.mjs");
153+
captureStart();
154+
await run("logs", ["prj_test123", "hello", "--tail=10"]);
155+
captureStop();
156+
157+
const call = calls.find((c) => /\/logs\?/.test(c.path));
158+
assert.ok(call, `expected logs request, got ${JSON.stringify(calls)}`);
159+
assert.match(call.url, /tail=10/);
160+
});
161+
});
162+
163+
describe("numeric flag validation", () => {
164+
it("blob ls validates --limit before network (GH-186)", async () => {
165+
const { run } = await import("./cli/lib/blob.mjs");
166+
for (const value of ["notanumber", "0", "999999"]) {
167+
calls = [];
168+
const err = await expectExit1(() => run("ls", ["--project", "prj_test123", "--limit", value]));
169+
assert.equal(err.code, "BAD_FLAG");
170+
assert.match(err.message, /--limit/);
171+
assert.equal(calls.length, 0, `bad --limit ${value} must not hit network`);
172+
}
173+
});
174+
175+
it("blob sign validates --ttl before network (GH-186)", async () => {
176+
const { run } = await import("./cli/lib/blob.mjs");
177+
for (const value of ["abc", "-1", "99999999"]) {
178+
calls = [];
179+
const err = await expectExit1(() => run("sign", ["reports/a.pdf", "--project", "prj_test123", "--ttl", value]));
180+
assert.equal(err.code, "BAD_FLAG");
181+
assert.match(err.message, /--ttl/);
182+
assert.equal(calls.length, 0, `bad --ttl ${value} must not hit network`);
183+
}
184+
});
185+
186+
it("blob put validates --concurrency before upload init (GH-186)", async () => {
187+
const { run } = await import("./cli/lib/blob.mjs");
188+
const file = join(tempDir, "upload.txt");
189+
writeFileSync(file, "hello");
190+
const err = await expectExit1(() =>
191+
run("put", [file, "--project", "prj_test123", "--concurrency", "0"]));
192+
193+
assert.equal(err.code, "BAD_FLAG");
194+
assert.match(err.message, /--concurrency/);
195+
assert.equal(calls.length, 0, "bad --concurrency must not init an upload");
196+
});
197+
198+
it("blob put surfaces upload-init gateway errors as structured JSON (GH-186)", async () => {
199+
const { run } = await import("./cli/lib/blob.mjs");
200+
const file = join(tempDir, "upload-init-fails.txt");
201+
writeFileSync(file, "hello");
202+
const prevFetch = globalThis.fetch;
203+
globalThis.fetch = (input, init) => {
204+
const info = requestInfo(input, init);
205+
calls.push(info);
206+
if (info.path === "/storage/v1/uploads" && info.method === "POST") {
207+
return Promise.resolve(json({
208+
error: "Invalid apikey",
209+
message: "Invalid apikey",
210+
code: "INVALID_AUTH",
211+
trace_id: "trc_init",
212+
}, 401));
213+
}
214+
return mockFetch(input, init);
215+
};
216+
const err = await expectExit1(() =>
217+
run("put", [file, "--project", "prj_test123", "--concurrency", "1"]));
218+
globalThis.fetch = prevFetch;
219+
220+
assert.equal(err.http, 401);
221+
assert.equal(err.code, "INVALID_AUTH");
222+
assert.equal(err.trace_id, "trc_init");
223+
assert.ok(!/\\\"code\\\"/.test(err.message ?? ""), `message should not contain stringified JSON: ${err.message}`);
224+
});
225+
226+
it("functions update validates --memory before network (GH-186)", async () => {
227+
const { run } = await import("./cli/lib/functions.mjs");
228+
const err = await expectExit1(() =>
229+
run("update", ["prj_test123", "hello", "--memory", "abc"]));
230+
231+
assert.equal(err.code, "BAD_FLAG");
232+
assert.match(err.message, /--memory/);
233+
assert.equal(calls.length, 0, "bad --memory must not hit network");
234+
});
235+
});
236+
237+
describe("project-id heuristic", () => {
238+
it("projects info refuses non-prj first positional instead of using active project (GH-184)", async () => {
239+
const { run } = await import("./cli/lib/projects.mjs");
240+
const err = await expectExit1(() => run("info", ["proj-001"]));
241+
242+
assert.equal(err.code, "BAD_PROJECT_ID");
243+
assert.match(err.message, /proj-001/);
244+
assert.equal(calls.length, 0);
245+
});
246+
247+
it("projects sql keeps one-arg query active-project shorthand but rejects bad-id plus extra query (GH-184)", async () => {
248+
const { run } = await import("./cli/lib/projects.mjs");
249+
250+
captureStart();
251+
await run("sql", ["SELECT 1"]);
252+
captureStop();
253+
assert.equal(calls.some((c) => /\/projects\/v1\/admin\/prj_test123\/sql$/.test(c.path)), true);
254+
255+
calls = [];
256+
const err = await expectExit1(() => run("sql", ["badly-typed-id", "DELETE FROM users"]));
257+
assert.equal(err.code, "BAD_PROJECT_ID");
258+
assert.match(err.message, /badly-typed-id/);
259+
assert.equal(calls.length, 0);
260+
});
261+
262+
it("projects sql refuses a non-prj positional before --file (GH-184)", async () => {
263+
const { run } = await import("./cli/lib/projects.mjs");
264+
const sqlFile = join(tempDir, "danger.sql");
265+
writeFileSync(sqlFile, "SELECT 1");
266+
267+
const err = await expectExit1(() => run("sql", ["proj-001", "--file", sqlFile]));
268+
assert.equal(err.code, "BAD_PROJECT_ID");
269+
assert.match(err.message, /proj-001/);
270+
assert.equal(calls.length, 0);
271+
});
272+
});

cli/lib/ai.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ Subcommands:
1313
usage <project_id>
1414
1515
Examples:
16-
run402 ai translate proj-001 "Hello world" --to es
17-
run402 ai translate proj-001 "Hello" --to ja --from en --context "formal business email"
18-
run402 ai moderate proj-001 "content to check"
19-
run402 ai usage proj-001
16+
run402 ai translate prj_abc123 "Hello world" --to es
17+
run402 ai translate prj_abc123 "Hello" --to ja --from en --context "formal business email"
18+
run402 ai moderate prj_abc123 "content to check"
19+
run402 ai usage prj_abc123
2020
2121
Notes:
2222
- translate requires the AI Translation add-on on the project
@@ -44,8 +44,8 @@ Notes:
4444
- Counts against the project's translation word quota
4545
4646
Examples:
47-
run402 ai translate proj-001 "Hello world" --to es
48-
run402 ai translate proj-001 "Hello" --to ja --from en \\
47+
run402 ai translate prj_abc123 "Hello world" --to es
48+
run402 ai translate prj_abc123 "Hello" --to ja --from en \\
4949
--context "formal business email"
5050
`,
5151
};

cli/lib/apps.mjs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ Examples:
2323
run402 apps browse
2424
run402 apps browse --tag auth
2525
run402 apps fork ver_abc123 my-todo --tier prototype
26-
run402 apps publish proj123 --description "Todo app" --tags todo,auth --visibility public --fork-allowed
27-
run402 apps versions proj123
26+
run402 apps publish prj_abc123 --description "Todo app" --tags todo,auth --visibility public --fork-allowed
27+
run402 apps versions prj_abc123
2828
run402 apps inspect ver_abc123
29-
run402 apps update proj123 ver_abc123 --description "Updated" --tags todo
30-
run402 apps delete proj123 ver_abc123
29+
run402 apps update prj_abc123 ver_abc123 --description "Updated" --tags todo
30+
run402 apps delete prj_abc123 ver_abc123
3131
`;
3232

3333
const SUB_HELP = {
@@ -76,8 +76,8 @@ Options:
7676
--fork-allowed Allow other users to fork this app
7777
7878
Examples:
79-
run402 apps publish proj123 --description "Todo app" --tags todo,auth
80-
run402 apps publish proj123 --visibility public --fork-allowed
79+
run402 apps publish prj_abc123 --description "Todo app" --tags todo,auth
80+
run402 apps publish prj_abc123 --visibility public --fork-allowed
8181
`,
8282
update: `run402 apps update — Update a published version's metadata
8383
@@ -96,9 +96,9 @@ Options:
9696
--no-fork Disable forking for this version
9797
9898
Examples:
99-
run402 apps update proj123 ver_abc123 --description "Updated"
100-
run402 apps update proj123 ver_abc123 --tags todo,auth --fork-allowed
101-
run402 apps update proj123 ver_abc123 --no-fork
99+
run402 apps update prj_abc123 ver_abc123 --description "Updated"
100+
run402 apps update prj_abc123 ver_abc123 --tags todo,auth --fork-allowed
101+
run402 apps update prj_abc123 ver_abc123 --no-fork
102102
`,
103103
};
104104

0 commit comments

Comments
 (0)