run402 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ scanFileContent,
6
+ SCAN_SEVERITY,
7
+ _testOnly_hallucinatedNames,
8
+ _testOnly_authProperties,
9
+ } from "./doctor-source-scan.mjs";
10
+
11
+ describe("scanFileContent — hallucinated bare names", () => {
12
+ it("flags `import { getUser } from \"@run402/functions\"` as an error", () => {
13
+ const findings = scanFileContent(
14
+ `import { getUser } from "@run402/functions";\n`,
15
+ { filePath: "src/pages/index.astro" },
16
+ );
17
+ assert.ok(findings.length >= 1);
18
+ const f = findings.find((x) => x.attempted_name === "getUser" && x.line === 1);
19
+ assert.ok(f, "found getUser finding");
20
+ assert.equal(f.code, "R402_AUTH_UNKNOWN_EXPORT");
21
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
22
+ assert.match(f.canonical_name, /auth\.user/);
23
+ assert.equal(f.file, "src/pages/index.astro");
24
+ });
25
+
26
+ it("flags `import { getSession, currentUser } from \"@run402/functions\"`", () => {
27
+ const findings = scanFileContent(
28
+ `import { getSession, currentUser } from "@run402/functions";`,
29
+ );
30
+ const names = new Set(findings.map((f) => f.attempted_name));
31
+ assert.ok(names.has("getSession"));
32
+ assert.ok(names.has("currentUser"));
33
+ });
34
+
35
+ it("flags bare `await getSession()` call sites", () => {
36
+ const findings = scanFileContent(`
37
+ const session = await getSession();
38
+ `);
39
+ assert.ok(findings.some((f) => f.attempted_name === "getSession"));
40
+ });
41
+
42
+ it("does not double-fire on `auth.getSession()` — that's the property scanner's job", () => {
43
+ const findings = scanFileContent(`
44
+ const session = await auth.getSession();
45
+ `);
46
+ // The bare-name scanner should NOT fire on `auth.getSession`; only
47
+ // the property scanner should. So exactly one finding for getSession.
48
+ const bareGetSession = findings.filter((f) => f.attempted_name === "getSession");
49
+ assert.equal(bareGetSession.length, 0, "bare getSession() must not fire on auth.getSession()");
50
+ const authGetSession = findings.filter((f) => f.attempted_name === "auth.getSession");
51
+ assert.equal(authGetSession.length, 1);
52
+ });
53
+
54
+ it("includes the canonical replacement and docs URL in the finding", () => {
55
+ const findings = scanFileContent(
56
+ `import { getUser } from "@run402/functions";`,
57
+ );
58
+ const f = findings[0];
59
+ assert.match(f.canonical_name, /auth\.user/);
60
+ assert.match(f.import_line, /@run402\/functions/);
61
+ assert.match(f.docs, /docs\.run402\.com\/auth\/sdk/);
62
+ });
63
+
64
+ it("covers every hallucinated name in the spec registry", () => {
65
+ const names = _testOnly_hallucinatedNames();
66
+ for (const entry of names) {
67
+ const findings = scanFileContent(`const x = ${entry.name}();`);
68
+ assert.ok(
69
+ findings.some((f) => f.attempted_name === entry.name),
70
+ `expected scanner to flag bare ${entry.name}()`,
71
+ );
72
+ }
73
+ });
74
+
75
+ it("does NOT fire on `auth.user()` (the canonical helper)", () => {
76
+ const findings = scanFileContent(`
77
+ const user = await auth.user();
78
+ const required = await auth.requireUser();
79
+ `);
80
+ // Should be zero R402_AUTH_UNKNOWN_EXPORT findings.
81
+ assert.equal(findings.length, 0, `unexpected findings: ${JSON.stringify(findings)}`);
82
+ });
83
+ });
84
+
85
+ describe("scanFileContent — auth.* property hallucinations", () => {
86
+ it("flags `auth.protect(...)` as an error pointing at auth.requireUser / auth.requireRole", () => {
87
+ const findings = scanFileContent(`const r = auth.protect({ role: "admin" });`);
88
+ const f = findings.find((x) => x.attempted_name === "auth.protect");
89
+ assert.ok(f);
90
+ assert.match(f.canonical_name, /requireUser|requireRole/);
91
+ });
92
+
93
+ it("flags `auth.signIn(...)` pointing at createResponseFromIdentity", () => {
94
+ const findings = scanFileContent(`return auth.signIn({ provider: "google" });`);
95
+ const f = findings.find((x) => x.attempted_name === "auth.signIn");
96
+ assert.ok(f);
97
+ assert.match(f.canonical_name, /createResponseFromIdentity|POST \/auth\/sign-in/);
98
+ });
99
+
100
+ it("covers every auth.* property in the registry", () => {
101
+ const props = _testOnly_authProperties();
102
+ for (const entry of props) {
103
+ const findings = scanFileContent(`const r = ${entry.name}();`);
104
+ assert.ok(
105
+ findings.some((f) => f.attempted_name === entry.name),
106
+ `expected scanner to flag ${entry.name}`,
107
+ );
108
+ }
109
+ });
110
+ });
111
+
112
+ describe("scanFileContent — browser-only patterns", () => {
113
+ it("flags `localStorage.getItem(\"wl_session\")`", () => {
114
+ const findings = scanFileContent(`
115
+ const token = localStorage.getItem("wl_session");
116
+ `);
117
+ assert.ok(findings.some((f) => f.attempted_name === "localStorage.wl_session"));
118
+ });
119
+
120
+ it("warns on Authorization: Bearer in browser-context code", () => {
121
+ const findings = scanFileContent(`
122
+ fetch("/api", { headers: { "Authorization": "Bearer " + token } });
123
+ `);
124
+ const f = findings.find((x) => x.attempted_name?.startsWith("Authorization"));
125
+ assert.ok(f);
126
+ assert.equal(f.severity, SCAN_SEVERITY.WARN, "Bearer is a warn, not an error");
127
+ });
128
+ });
129
+
130
+ describe("scanFileContent — prerendered pages calling auth.*", () => {
131
+ it("flags `export const prerender = true` + `auth.user()`", () => {
132
+ const findings = scanFileContent(
133
+ `---
134
+ export const prerender = true;
135
+ const user = await auth.user();
136
+ ---
137
+ <html><body>{user?.email}</body></html>`,
138
+ { filePath: "src/pages/me.astro" },
139
+ );
140
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
141
+ assert.ok(f, "expected R402_AUTH_PRERENDERED");
142
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
143
+ assert.match(f.docs, /rendering-modes/);
144
+ });
145
+
146
+ it("does NOT fire when `prerender = true` page never calls auth.*", () => {
147
+ const findings = scanFileContent(
148
+ `---
149
+ export const prerender = true;
150
+ const title = "static page";
151
+ ---
152
+ <html><body>{title}</body></html>`,
153
+ { filePath: "src/pages/static.astro" },
154
+ );
155
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
156
+ assert.equal(f, undefined);
157
+ });
158
+
159
+ it("does NOT fire when the file lacks the prerender export", () => {
160
+ const findings = scanFileContent(
161
+ `const user = await auth.user();`,
162
+ { filePath: "src/pages/dynamic.astro" },
163
+ );
164
+ const f = findings.find((x) => x.code === "R402_AUTH_PRERENDERED");
165
+ assert.equal(f, undefined);
166
+ });
167
+ });
168
+
169
+ describe("scanFileContent — state-changing GET handlers", () => {
170
+ it("flags `export async function GET` with `db().insert(...)`", () => {
171
+ const findings = scanFileContent(`
172
+ import { db } from "@run402/functions";
173
+ export async function GET(req) {
174
+ await db().from("posts").insert({ title: "x" });
175
+ return new Response("ok");
176
+ }
177
+ `);
178
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
179
+ assert.ok(f);
180
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
181
+ });
182
+
183
+ it("flags `export const GET` with `adminDb().sql(\"UPDATE ...\")`", () => {
184
+ const findings = scanFileContent(`
185
+ import { adminDb } from "@run402/functions";
186
+ export const GET = async () => {
187
+ await adminDb().sql("UPDATE foo SET x = 1");
188
+ return new Response("ok");
189
+ };
190
+ `);
191
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
192
+ assert.ok(f);
193
+ });
194
+
195
+ it("does NOT fire on read-only GET handlers", () => {
196
+ const findings = scanFileContent(`
197
+ import { db } from "@run402/functions";
198
+ export async function GET() {
199
+ const rows = await db().from("posts").select();
200
+ return Response.json(rows);
201
+ }
202
+ `);
203
+ const f = findings.find((x) => x.code === "R402_AUTH_STATE_CHANGING_GET");
204
+ assert.equal(f, undefined);
205
+ });
206
+ });
207
+
208
+ describe("scanFileContent — direct authz_version mutation", () => {
209
+ it("flags `UPDATE internal.sessions SET authz_version`", () => {
210
+ const findings = scanFileContent(`
211
+ adminDb().sql(\`UPDATE internal.sessions SET authz_version = authz_version + 1\`);
212
+ `);
213
+ const f = findings.find((x) => x.code === "R402_AUTH_AUTHZ_VERSION_PROHIBITED");
214
+ assert.ok(f);
215
+ assert.equal(f.severity, SCAN_SEVERITY.ERROR);
216
+ assert.match(f.docs, /authz-version/);
217
+ });
218
+
219
+ it("is case-insensitive (UPDATE / update / Update)", () => {
220
+ const variants = [
221
+ "update internal.sessions set authz_version = 5;",
222
+ "Update Internal.Sessions Set Authz_Version = 5;",
223
+ ];
224
+ for (const sql of variants) {
225
+ const findings = scanFileContent(sql);
226
+ assert.ok(
227
+ findings.some((f) => f.code === "R402_AUTH_AUTHZ_VERSION_PROHIBITED"),
228
+ `case variant failed: ${sql}`,
229
+ );
230
+ }
231
+ });
232
+ });
233
+
234
+ describe("scanFileContent — redundant user_id filter (R402_AUTH_REDUNDANT_USER_FILTER)", () => {
235
+ it("flags `.eq(\"user_id\", user.id)`", () => {
236
+ const content = [
237
+ 'import { db, auth } from "@run402/functions";',
238
+ "const user = await auth.requireUser();",
239
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id);',
240
+ ].join("\n");
241
+ const findings = scanFileContent(content);
242
+ const f = findings.find((x) => x.code === "R402_AUTH_REDUNDANT_USER_FILTER");
243
+ assert.ok(f, "expected a R402_AUTH_REDUNDANT_USER_FILTER finding");
244
+ assert.equal(f.severity, SCAN_SEVERITY.WARN);
245
+ assert.match(f.docs, /R402_AUTH_REDUNDANT_USER_FILTER/);
246
+ assert.equal(f.line, 3);
247
+ });
248
+
249
+ it("flags `.eq('user_id', actor.id)` with single quotes + different identifier", () => {
250
+ const content = "const r = q.eq('user_id', actor.id);";
251
+ const findings = scanFileContent(content);
252
+ assert.ok(findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"));
253
+ });
254
+
255
+ it("is silenced by `// run402-allow-user-filter:` on the same line", () => {
256
+ const content = [
257
+ 'import { db, auth } from "@run402/functions";',
258
+ "const user = await auth.requireUser();",
259
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id); // run402-allow-user-filter: explicit filter for analytics export',
260
+ ].join("\n");
261
+ const findings = scanFileContent(content);
262
+ assert.ok(
263
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
264
+ "annotated line should not produce a finding",
265
+ );
266
+ });
267
+
268
+ it("is silenced by `// run402-allow-user-filter:` on the preceding line", () => {
269
+ const content = [
270
+ 'import { db, auth } from "@run402/functions";',
271
+ "const user = await auth.requireUser();",
272
+ "// run402-allow-user-filter: this table's RLS scopes on org_id, not user_id",
273
+ 'const rows = await db().from("posts").select("*").eq("user_id", user.id);',
274
+ ].join("\n");
275
+ const findings = scanFileContent(content);
276
+ assert.ok(
277
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
278
+ "preceding annotation should silence",
279
+ );
280
+ });
281
+
282
+ it("does NOT flag `.eq(\"org_id\", user.org_id)` or `.eq(\"team_id\", ...)`", () => {
283
+ const content = [
284
+ 'const rows1 = q.eq("org_id", user.id);', // non-user_id column
285
+ "const rows2 = q.eq(\"team_id\", actor.team_id);",
286
+ 'const rows3 = q.eq("user_id", "fixed-uuid-literal");', // literal string, not <ident>.id
287
+ ].join("\n");
288
+ const findings = scanFileContent(content);
289
+ assert.ok(
290
+ !findings.some((f) => f.code === "R402_AUTH_REDUNDANT_USER_FILTER"),
291
+ "non-matching patterns should not fire",
292
+ );
293
+ });
294
+ });
295
+
296
+ describe("scanFileContent — line numbers + file paths", () => {
297
+ it("reports the line of the violation, not always line 1", () => {
298
+ const content = [
299
+ "// line 1: comment",
300
+ "// line 2: comment",
301
+ 'import { auth } from "@run402/functions";',
302
+ "// line 4: comment",
303
+ 'const u = await getSession(); // line 5',
304
+ "",
305
+ ].join("\n");
306
+ const findings = scanFileContent(content);
307
+ const f = findings.find((x) => x.attempted_name === "getSession");
308
+ assert.ok(f);
309
+ assert.equal(f.line, 5);
310
+ });
311
+
312
+ it("propagates filePath from the caller", () => {
313
+ const findings = scanFileContent(`const u = await getSession();`, {
314
+ filePath: "src/pages/account.astro",
315
+ });
316
+ assert.equal(findings[0].file, "src/pages/account.astro");
317
+ });
318
+ });
package/lib/doctor.mjs CHANGED
@@ -14,15 +14,24 @@
14
14
  import { existsSync, statSync } from "node:fs";
15
15
  import { CONFIG_DIR, readAllowance, loadKeyStore } from "./config.mjs";
16
16
  import { getSdk } from "./sdk.mjs";
17
+ import {
18
+ resolveScanRoot,
19
+ scanSourceTree,
20
+ SCAN_SEVERITY,
21
+ } from "./doctor-source-scan.mjs";
17
22
 
18
23
  const HELP = `run402 doctor — Health and config diagnostics
19
24
 
20
25
  Usage:
21
- run402 doctor [--json] [--verbose]
26
+ run402 doctor [--verbose] [--no-scan] [--scan-dir <D>]
27
+
28
+ Output:
29
+ Stdout is a JSON report { ok, checks: [{ name, status, value?, hint?, message? }] }.
22
30
 
23
31
  Options:
24
- --json Emit a structured JSON report on stdout
25
- --verbose Include extra detail (timing, error messages)
32
+ --verbose Include extra detail (timing, error messages)
33
+ --no-scan Skip the source-tree scan (config / health checks only)
34
+ --scan-dir D Scan a custom directory instead of \`<cwd>/src\`
26
35
 
27
36
  Checks performed:
28
37
  - Config directory exists and is writable
@@ -30,6 +39,11 @@ Checks performed:
30
39
  - Keystore has at least one wallet
31
40
  - API_BASE is reachable (network check via /health)
32
41
  - Active tier resolves and is not 'past_due' / 'frozen'
42
+ - Source scan: hallucinated SDK auth names (R402_AUTH_UNKNOWN_EXPORT),
43
+ state-changing GET handlers (R402_AUTH_STATE_CHANGING_GET),
44
+ auth.* calls in prerendered pages (R402_AUTH_PRERENDERED),
45
+ direct mutation of internal.sessions.authz_version
46
+ (R402_AUTH_AUTHZ_VERSION_PROHIBITED).
33
47
 
34
48
  Exit codes:
35
49
  0 — all checks pass
@@ -42,8 +56,10 @@ export async function run(sub, args = []) {
42
56
  console.log(HELP);
43
57
  return;
44
58
  }
45
- const json = all.includes("--json");
46
59
  const verbose = all.includes("--verbose");
60
+ const skipScan = all.includes("--no-scan");
61
+ const scanDirArgIdx = all.indexOf("--scan-dir");
62
+ const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
47
63
 
48
64
  const checks = [];
49
65
 
@@ -222,32 +238,49 @@ export async function run(sub, args = []) {
222
238
  });
223
239
  }
224
240
 
241
+ // 7. Source-tree scan (auth-aware-ssr Section 9). Detects hallucinated
242
+ // SDK names, state-changing GETs, auth.* in prerendered pages, and
243
+ // direct mutation of internal.sessions.authz_version. Hits with severity
244
+ // `error` block deploy (`run402 deploy` wraps doctor and respects exit
245
+ // code). Skipped via --no-scan when the user wants config-only checks.
246
+ if (!skipScan) {
247
+ try {
248
+ const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
249
+ const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
250
+ const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
251
+ const warnFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.WARN);
252
+ if (findings.length === 0) {
253
+ checks.push({ name: "source_scan", status: "ok", value: { scan_root: scanRoot, file_count_with_findings: 0 } });
254
+ } else {
255
+ checks.push({
256
+ name: "source_scan",
257
+ status: errorFindings.length > 0 ? "error" : "warning",
258
+ value: {
259
+ scan_root: scanRoot,
260
+ findings: errorFindings.length + warnFindings.length,
261
+ errors: errorFindings.length,
262
+ warnings: warnFindings.length,
263
+ details: findings,
264
+ },
265
+ hint: errorFindings.length > 0
266
+ ? "Fix the R402_AUTH_* findings above. `run402 deploy` will refuse to ship until these are resolved."
267
+ : "Source scan emitted warnings (non-blocking). Review and address when convenient.",
268
+ });
269
+ }
270
+ } catch (err) {
271
+ checks.push({
272
+ name: "source_scan",
273
+ status: "skipped",
274
+ message: err instanceof Error ? err.message : String(err),
275
+ });
276
+ }
277
+ }
278
+
225
279
  // 'warning' counts as ok for exit-code purposes — gaps are surfaced in
226
280
  // output but don't fail the doctor. Only hard 'error' / 'missing' /
227
281
  // 'empty' fail.
228
282
  const allOk = checks.every((c) => c.status === "ok" || c.status === "warning" || c.status === "skipped");
229
283
 
230
- if (json) {
231
- console.log(JSON.stringify({ ok: allOk, checks }, null, 2));
232
- } else {
233
- console.log(`Run402 doctor — ${allOk ? "all checks passed" : "issues found"}`);
234
- console.log("");
235
- for (const c of checks) {
236
- const icon =
237
- c.status === "ok" ? "✓"
238
- : c.status === "warning" ? "⚠"
239
- : c.status === "skipped" ? "·"
240
- : c.status === "missing" || c.status === "empty" ? "⚠"
241
- : "✗";
242
- const status = c.status === "ok" ? "ok" : c.status;
243
- console.log(` ${icon} ${c.name.padEnd(16)} ${status}`);
244
- if (c.hint) console.log(` → ${c.hint}`);
245
- if (c.message) console.log(` ${c.message}`);
246
- if (c.value && c.value.gaps && Array.isArray(c.value.gaps)) {
247
- for (const gap of c.value.gaps) console.log(` • ${gap}`);
248
- }
249
- }
250
- }
251
-
284
+ console.log(JSON.stringify({ ok: allOk, checks }, null, 2));
252
285
  process.exit(allOk ? 0 : 1);
253
286
  }
package/lib/email.mjs CHANGED
@@ -16,8 +16,11 @@ Subcommands:
16
16
  list [--limit <n>] [--after <cursor>] [--project <id>]
17
17
  List sent/received messages (paginated)
18
18
  get <message_id> [--project <id>] Get a message with replies
19
- get-raw <message_id> [--project <id>] [--output <file>]
20
- Fetch raw RFC-822 bytes (inbound only)
19
+ get-raw <message_id> --output <file> [--project <id>]
20
+ Fetch raw RFC-822 bytes (inbound only).
21
+ --output is required: bytes are written
22
+ to the file; stdout receives a JSON
23
+ envelope { message_id, bytes, output }.
21
24
  reply <message_id> --html "..." [--text "..."] [--subject "..."] [--from-name "..."] [--project <id>]
22
25
  Reply to an inbound message (threads via In-Reply-To)
23
26
  delete [<slug|mailbox_id>] --confirm [--project <id>]
@@ -123,7 +126,19 @@ compatibility; new code should use 'info'.
123
126
  "get-raw": `run402 email get-raw — Fetch raw RFC-822 bytes for an inbound message
124
127
 
125
128
  Usage:
126
- run402 email get-raw <message_id> [--output <file>] [--project <id>]
129
+ run402 email get-raw <message_id> --output <file> [--project <id>]
130
+
131
+ Arguments:
132
+ <message_id> Inbound message ID
133
+
134
+ Options:
135
+ --output <file> Required: destination file for the raw RFC-822 bytes.
136
+ stdout receives a JSON envelope
137
+ { message_id, bytes, output } — the MIME body is never
138
+ written to stdout, so the CLI stays pipeable.
139
+ --project <id> Project ID (defaults to the active project)
140
+ --mailbox <slug|id> Target a specific mailbox (required when the project
141
+ has more than one)
127
142
  `,
128
143
  create: `run402 email create — Create a project mailbox
129
144
 
@@ -321,21 +336,24 @@ async function getRaw(args) {
321
336
  fail({
322
337
  code: "BAD_USAGE",
323
338
  message: "Missing message_id.",
324
- hint: "run402 email get-raw <message_id> [--output <file>]",
339
+ hint: "run402 email get-raw <message_id> --output <file>",
340
+ });
341
+ }
342
+ if (!outputFile) {
343
+ fail({
344
+ code: "BAD_USAGE",
345
+ message: "Missing --output <file>. Raw MIME bytes must be written to a file, not stdout.",
346
+ hint: "run402 email get-raw <message_id> --output <file>",
347
+ details: { flag: "--output" },
325
348
  });
326
349
  }
327
350
 
328
351
  try {
329
352
  const result = await getSdk().email.getRaw(projectId, messageId, { mailbox: mailbox ?? undefined });
330
353
  const buf = Buffer.from(result.bytes);
331
-
332
- if (outputFile) {
333
- const { writeFileSync } = await import("node:fs");
334
- writeFileSync(outputFile, buf);
335
- console.log(JSON.stringify({ message_id: messageId, bytes: buf.length, output: outputFile }));
336
- } else {
337
- process.stdout.write(buf);
338
- }
354
+ const { writeFileSync } = await import("node:fs");
355
+ writeFileSync(outputFile, buf);
356
+ console.log(JSON.stringify({ message_id: messageId, bytes: buf.length, output: outputFile }));
339
357
  } catch (err) {
340
358
  reportSdkError(err);
341
359
  }
package/lib/functions.mjs CHANGED
@@ -15,8 +15,12 @@ Usage:
15
15
  Subcommands:
16
16
  deploy <id> <name> --file <file> [--timeout <s>] [--memory <mb>] [--deps <pkg,...>] [--schedule <cron>]
17
17
  Deploy a function to a project
18
- invoke <id> <name> [--method <M>] [--body <json>]
19
- Invoke a deployed function
18
+ invoke <id> <name> [--method <M>] [--body <json>] [--raw]
19
+ Invoke a deployed function. Default
20
+ wraps the SDK result as JSON on stdout.
21
+ --raw prints the response body verbatim
22
+ (string body → text + newline, JSON
23
+ body → pretty-printed JSON).
20
24
  logs <id> <name> [--tail <n>] [--since <ts>] [--request-id <req_...>] [--follow]
21
25
  Get function logs
22
26
  update <id> <name> [--schedule <cron>] [--schedule-remove] [--timeout <s>] [--memory <mb>]
@@ -99,10 +103,22 @@ Arguments:
99
103
  Options:
100
104
  --method <M> HTTP method (default POST)
101
105
  --body <json> Request body (ignored for GET/HEAD)
106
+ --raw Skip JSON wrapping. Prints the response body verbatim:
107
+ string body → text + trailing newline; JSON body →
108
+ pretty-printed JSON. Useful when piping a text/plain
109
+ function response to another tool.
110
+
111
+ Output (default — without --raw):
112
+ Stdout is a single JSON envelope { http_status, body, duration_ms }.
113
+ Safe to pipe to jq even when the function returns a plain string.
114
+ The HTTP status is exposed as http_status (not status) so the payload
115
+ stays clean of the reserved top-level "status" field used in error
116
+ envelopes on stderr.
102
117
 
103
118
  Examples:
104
119
  run402 functions invoke prj_abc123 stripe-webhook --body '{"event":"test"}'
105
120
  run402 functions invoke prj_abc123 ping --method GET
121
+ run402 functions invoke prj_abc123 csv --raw > export.csv
106
122
  `,
107
123
  logs: `run402 functions logs — Fetch or tail function logs
108
124
 
@@ -117,7 +133,10 @@ Options:
117
133
  --tail <n> Number of most-recent entries (default 50, max 1000)
118
134
  --since <ts> ISO timestamp or epoch ms; only entries after this
119
135
  --request-id <id> Only entries correlated to this req_... request id
120
- --follow Poll every 3s and stream new entries (Ctrl-C to stop)
136
+ --follow Poll every 3s and stream new entries (Ctrl-C to stop).
137
+ Emits NDJSON: one JSON log entry per line, no wrapping
138
+ "logs:" envelope (the wrapping object is only used in
139
+ the non-follow batch mode).
121
140
 
122
141
  Examples:
123
142
  run402 functions logs prj_abc123 stripe-webhook --tail 100
@@ -208,12 +227,13 @@ async function deploy(projectId, name, args) {
208
227
  }
209
228
 
210
229
  async function invoke(projectId, name, args) {
211
- assertRequiredProjectAndName(projectId, name, "run402 functions invoke <project_id> <name> [--method <M>] [--body <json>]");
212
- assertKnownFlags(args, ["--method", "--body", "--help", "-h"], ["--method", "--body"]);
213
- const opts = { method: "POST", body: undefined };
230
+ assertRequiredProjectAndName(projectId, name, "run402 functions invoke <project_id> <name> [--method <M>] [--body <json>] [--raw]");
231
+ assertKnownFlags(args, ["--method", "--body", "--raw", "--help", "-h"], ["--method", "--body"]);
232
+ const opts = { method: "POST", body: undefined, raw: false };
214
233
  for (let i = 0; i < args.length; i++) {
215
234
  if (args[i] === "--method" && args[i + 1]) opts.method = args[++i];
216
235
  if (args[i] === "--body" && args[i + 1]) opts.body = args[++i];
236
+ if (args[i] === "--raw") opts.raw = true;
217
237
  }
218
238
  const invokeOpts = { method: opts.method };
219
239
  if (opts.body !== undefined && opts.method !== "GET" && opts.method !== "HEAD") {
@@ -221,12 +241,17 @@ async function invoke(projectId, name, args) {
221
241
  }
222
242
  try {
223
243
  const result = await getSdk().functions.invoke(projectId, name, invokeOpts);
224
- const body = result.body;
225
- if (typeof body === "string") {
226
- process.stdout.write(body + "\n");
227
- } else {
228
- console.log(JSON.stringify(body, null, 2));
244
+ if (opts.raw) {
245
+ const body = result.body;
246
+ if (typeof body === "string") {
247
+ process.stdout.write(body + "\n");
248
+ } else {
249
+ console.log(JSON.stringify(body, null, 2));
250
+ }
251
+ return;
229
252
  }
253
+ const { status, ...rest } = result;
254
+ console.log(JSON.stringify({ http_status: status, ...rest }, null, 2));
230
255
  } catch (err) {
231
256
  reportSdkError(err);
232
257
  }
@@ -310,7 +335,7 @@ async function logs(projectId, name, args) {
310
335
  }
311
336
 
312
337
  for (const { entry } of fresh) {
313
- console.log(`[${entry.timestamp}] ${entry.message}`);
338
+ console.log(JSON.stringify(entry));
314
339
  }
315
340
  if (fresh.length === 0 || !Number.isFinite(nextHighWaterMs)) return;
316
341