Skip to content

Commit 7b7b91b

Browse files
committed
fix cli argv parsing and sdk parity
1 parent 8cb123b commit 7b7b91b

27 files changed

Lines changed: 981 additions & 128 deletions

cli-argv.test.mjs

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

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)