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.
- package/lib/assets.mjs +18 -6
- package/lib/cache.mjs +11 -47
- package/lib/deploy-v2.mjs +38 -0
- package/lib/doctor-source-scan.mjs +424 -0
- package/lib/doctor-source-scan.test.mjs +318 -0
- package/lib/doctor.mjs +59 -26
- package/lib/email.mjs +30 -12
- package/lib/functions.mjs +37 -12
- package/lib/init-astro.mjs +127 -35
- package/lib/init.mjs +10 -10
- package/lib/logs.mjs +32 -37
- package/package.json +1 -1
|
@@ -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 [--
|
|
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
|
-
--
|
|
25
|
-
--
|
|
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
|
-
|
|
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>
|
|
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>
|
|
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>
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
338
|
+
console.log(JSON.stringify(entry));
|
|
314
339
|
}
|
|
315
340
|
if (fresh.length === 0 || !Number.isFinite(nextHighWaterMs)) return;
|
|
316
341
|
|