run402 2.10.0 → 2.11.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/cli.mjs +24 -0
- package/lib/cache.mjs +264 -0
- package/lib/dev.mjs +137 -0
- package/lib/doctor.mjs +188 -0
- package/lib/init-astro.mjs +277 -0
- package/lib/init.mjs +14 -0
- package/lib/logs.mjs +168 -0
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +3 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +3 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/cache.d.ts +100 -0
- package/sdk/dist/namespaces/cache.d.ts.map +1 -0
- package/sdk/dist/namespaces/cache.js +104 -0
- package/sdk/dist/namespaces/cache.js.map +1 -0
package/cli.mjs
CHANGED
|
@@ -46,6 +46,10 @@ Commands:
|
|
|
46
46
|
contracts KMS contract wallets ($0.04/day rental + $0.000005/sign)
|
|
47
47
|
agent Manage agent identity (contact info)
|
|
48
48
|
service Run402 service health and availability (status, health)
|
|
49
|
+
cache Inspect and invalidate the SSR origin cache (inspect, invalidate)
|
|
50
|
+
doctor Health and config diagnostics (machine-readable with --json)
|
|
51
|
+
dev Run Astro dev with Run402 env + credentials in scope
|
|
52
|
+
logs Fetch function logs by request id (--request-id req_...)
|
|
49
53
|
|
|
50
54
|
Run 'run402 <command> --help' for detailed usage of each command.
|
|
51
55
|
|
|
@@ -226,6 +230,26 @@ switch (cmd) {
|
|
|
226
230
|
await run(sub, rest);
|
|
227
231
|
break;
|
|
228
232
|
}
|
|
233
|
+
case "cache": {
|
|
234
|
+
const { run } = await import("./lib/cache.mjs");
|
|
235
|
+
await run(sub, rest);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "doctor": {
|
|
239
|
+
const { run } = await import("./lib/doctor.mjs");
|
|
240
|
+
await run(sub, rest);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "logs": {
|
|
244
|
+
const { run } = await import("./lib/logs.mjs");
|
|
245
|
+
await run(sub, rest);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "dev": {
|
|
249
|
+
const { run } = await import("./lib/dev.mjs");
|
|
250
|
+
await run(sub, rest);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
229
253
|
default:
|
|
230
254
|
console.error(`Unknown command: ${cmd}\n`);
|
|
231
255
|
console.log(HELP);
|
package/lib/cache.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run402 cache — SSR cache inspection and invalidation.
|
|
3
|
+
*
|
|
4
|
+
* Capability `ssr-isr-cache` (Run402 v1.52). Used by AI coding agents
|
|
5
|
+
* and human developers to:
|
|
6
|
+
*
|
|
7
|
+
* - Inspect the cache row state for a URL (`cache inspect`)
|
|
8
|
+
* - Invalidate a specific URL, prefix, or entire host (`cache invalidate`)
|
|
9
|
+
*
|
|
10
|
+
* Both subcommands are project-scoped: the active project id is read
|
|
11
|
+
* from the SDK config, and any host referenced MUST be owned by that
|
|
12
|
+
* project (subdomain `*.run402.com` or attached custom domain). The
|
|
13
|
+
* gateway returns `R402_CACHE_INVALIDATION_HOST_FORBIDDEN` for
|
|
14
|
+
* cross-project attempts; the CLI surfaces that as a structured error.
|
|
15
|
+
*
|
|
16
|
+
* @see https://docs.run402.com/cache/concepts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getSdk } from "./sdk.mjs";
|
|
20
|
+
import { reportSdkError, fail } from "./sdk-errors.mjs";
|
|
21
|
+
import { assertKnownFlags, flagValue, normalizeArgv } from "./argparse.mjs";
|
|
22
|
+
|
|
23
|
+
// Locally-defined helpers — argparse.mjs's normalized form is a flat
|
|
24
|
+
// string array; we need to identify positional args (non-flag, non-flag-
|
|
25
|
+
// value tokens) and detect flag presence.
|
|
26
|
+
function hasFlag(args, flags) {
|
|
27
|
+
return args.some((a) => flags.includes(a));
|
|
28
|
+
}
|
|
29
|
+
function positionalArgs(args) {
|
|
30
|
+
// Filter out flags (--foo) AND values that immediately follow a
|
|
31
|
+
// value-taking flag. We approximate by treating any token after a
|
|
32
|
+
// --flag as the flag's value unless it starts with --.
|
|
33
|
+
const out = [];
|
|
34
|
+
const valueTakingFlags = new Set(["--locale", "--release-id", "--prefix", "--host"]);
|
|
35
|
+
for (let i = 0; i < args.length; i++) {
|
|
36
|
+
const token = args[i];
|
|
37
|
+
if (typeof token !== "string") continue;
|
|
38
|
+
if (token.startsWith("--")) {
|
|
39
|
+
if (valueTakingFlags.has(token)) i += 1; // skip the value
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
out.push(token);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const HELP = `run402 cache — SSR cache inspection and invalidation
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
run402 cache <subcommand> [args...]
|
|
51
|
+
|
|
52
|
+
Subcommands:
|
|
53
|
+
inspect <url> Inspect cache row state for a URL
|
|
54
|
+
invalidate <url> Invalidate a specific URL
|
|
55
|
+
invalidate --prefix <p> --host <h> Invalidate all rows under a path prefix
|
|
56
|
+
invalidate --all --host <h> Invalidate all rows for a host
|
|
57
|
+
|
|
58
|
+
Common flags:
|
|
59
|
+
--json Machine-readable output
|
|
60
|
+
--locale <code> (inspect only) Inspect a specific locale's row
|
|
61
|
+
(default: project's default locale)
|
|
62
|
+
--release-id <id> (inspect only) Inspect a specific release id
|
|
63
|
+
(default: project's active release)
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
run402 cache inspect https://eagles.kychon.com/the-guys
|
|
67
|
+
run402 cache inspect https://eagles.kychon.com/the-guys --locale es --json
|
|
68
|
+
run402 cache invalidate https://eagles.kychon.com/the-guys
|
|
69
|
+
run402 cache invalidate --prefix /blog/ --host eagles.kychon.com
|
|
70
|
+
run402 cache invalidate --all --host eagles.kychon.com
|
|
71
|
+
|
|
72
|
+
Notes:
|
|
73
|
+
- The cache key is canonicalized (case-preserving query, ignored fragments,
|
|
74
|
+
repeated-key ordering preserved). See the cache concepts doc for the
|
|
75
|
+
canonical-key formula.
|
|
76
|
+
- 'inspect' returns HIT (a fresh row exists) or MISS (no fresh row). It
|
|
77
|
+
NEVER returns BYPASS — BYPASS is a runtime decision based on incoming
|
|
78
|
+
request properties (cookies, auth headers, etc.), and inspect does not
|
|
79
|
+
issue a request.
|
|
80
|
+
- Invalidation is generation-guarded: an in-flight MISS render started
|
|
81
|
+
before the invalidate will NOT overwrite the freshly-cleared state.
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
export async function run(sub, args) {
|
|
85
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
86
|
+
console.log(HELP);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
switch (sub) {
|
|
91
|
+
case "inspect":
|
|
92
|
+
await inspect(args);
|
|
93
|
+
break;
|
|
94
|
+
case "invalidate":
|
|
95
|
+
await invalidate(args);
|
|
96
|
+
break;
|
|
97
|
+
default:
|
|
98
|
+
fail({
|
|
99
|
+
code: "BAD_USAGE",
|
|
100
|
+
message: `Unknown cache subcommand: ${sub}`,
|
|
101
|
+
next_actions: ["run402 cache --help"],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function inspect(args) {
|
|
107
|
+
const parsed = normalizeArgv(args);
|
|
108
|
+
assertKnownFlags(parsed, ["--json", "--locale", "--release-id", "--help", "-h"]);
|
|
109
|
+
if (hasFlag(parsed, ["--help", "-h"])) {
|
|
110
|
+
console.log(HELP);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const positionals = positionalArgs(parsed);
|
|
115
|
+
if (positionals.length === 0) {
|
|
116
|
+
fail({
|
|
117
|
+
code: "BAD_USAGE",
|
|
118
|
+
message: "Missing URL argument.",
|
|
119
|
+
next_actions: ["run402 cache inspect <url>"],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const url = positionals[0];
|
|
123
|
+
if (!url.startsWith("https://") && !url.startsWith("http://")) {
|
|
124
|
+
fail({
|
|
125
|
+
code: "BAD_USAGE",
|
|
126
|
+
message: `URL must be absolute (got: ${url})`,
|
|
127
|
+
next_actions: ["run402 cache inspect https://<host>/<path>"],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const locale = flagValue(parsed, "--locale");
|
|
132
|
+
const releaseId = flagValue(parsed, "--release-id");
|
|
133
|
+
const json = hasFlag(parsed, ["--json"]);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const sdk = getSdk();
|
|
137
|
+
// SDK shape — the gateway's cache inspect endpoint isn't yet wired
|
|
138
|
+
// (separate task). For now the CLI POSTs to the same /cache/v1/
|
|
139
|
+
// namespace with kind=inspect.
|
|
140
|
+
const result = await sdk.cache.inspect(url, { locale, releaseId });
|
|
141
|
+
if (json) {
|
|
142
|
+
console.log(JSON.stringify(result, null, 2));
|
|
143
|
+
} else {
|
|
144
|
+
console.log(formatInspectResult(result));
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
reportSdkError(err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function invalidate(args) {
|
|
152
|
+
const parsed = normalizeArgv(args);
|
|
153
|
+
assertKnownFlags(parsed, ["--json", "--prefix", "--host", "--all", "--help", "-h"]);
|
|
154
|
+
if (hasFlag(parsed, ["--help", "-h"])) {
|
|
155
|
+
console.log(HELP);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const positionals = positionalArgs(parsed);
|
|
160
|
+
const json = hasFlag(parsed, ["--json"]);
|
|
161
|
+
const prefix = flagValue(parsed, "--prefix");
|
|
162
|
+
const host = flagValue(parsed, "--host");
|
|
163
|
+
const all = hasFlag(parsed, ["--all"]);
|
|
164
|
+
|
|
165
|
+
const sdk = getSdk();
|
|
166
|
+
|
|
167
|
+
// Three modes: exact URL, prefix, all.
|
|
168
|
+
if (all) {
|
|
169
|
+
if (!host) {
|
|
170
|
+
fail({
|
|
171
|
+
code: "BAD_USAGE",
|
|
172
|
+
message: "--all requires --host <hostname>",
|
|
173
|
+
next_actions: ["run402 cache invalidate --all --host <hostname>"],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const result = await sdk.cache.invalidateAll({ host });
|
|
178
|
+
emit(result, json);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
reportSdkError(err);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (prefix) {
|
|
186
|
+
if (!host) {
|
|
187
|
+
fail({
|
|
188
|
+
code: "BAD_USAGE",
|
|
189
|
+
message: "--prefix requires --host <hostname>",
|
|
190
|
+
next_actions: ["run402 cache invalidate --prefix /blog/ --host <hostname>"],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (!prefix.startsWith("/")) {
|
|
194
|
+
fail({
|
|
195
|
+
code: "BAD_USAGE",
|
|
196
|
+
message: `prefix must start with '/' (got: ${prefix})`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const result = await sdk.cache.invalidatePrefix({ host, prefix });
|
|
201
|
+
emit(result, json);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
reportSdkError(err);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Exact URL form.
|
|
209
|
+
if (positionals.length === 0) {
|
|
210
|
+
fail({
|
|
211
|
+
code: "BAD_USAGE",
|
|
212
|
+
message: "Missing URL argument.",
|
|
213
|
+
next_actions: [
|
|
214
|
+
"run402 cache invalidate <url>",
|
|
215
|
+
"run402 cache invalidate --prefix /blog/ --host <hostname>",
|
|
216
|
+
"run402 cache invalidate --all --host <hostname>",
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const url = positionals[0];
|
|
221
|
+
if (!url.startsWith("https://") && !url.startsWith("http://")) {
|
|
222
|
+
fail({
|
|
223
|
+
code: "BAD_USAGE",
|
|
224
|
+
message: `URL must be absolute (got: ${url})`,
|
|
225
|
+
next_actions: ["run402 cache invalidate https://<host>/<path>"],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const result = await sdk.cache.invalidate(url);
|
|
230
|
+
emit(result, json);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
reportSdkError(err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function emit(result, json) {
|
|
237
|
+
if (json) {
|
|
238
|
+
console.log(JSON.stringify(result, null, 2));
|
|
239
|
+
} else {
|
|
240
|
+
const parts = [`Invalidated ${result.deleted} cache row(s)`];
|
|
241
|
+
if (result.host) parts.push(`on ${result.host}`);
|
|
242
|
+
if (result.path) parts.push(`for ${result.path}`);
|
|
243
|
+
parts.push(`(generation: ${result.generation})`);
|
|
244
|
+
console.log(parts.join(" "));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function formatInspectResult(result) {
|
|
249
|
+
if (result.status === "MISS") {
|
|
250
|
+
return `MISS — no cache row for ${result.url || "this URL"}.`;
|
|
251
|
+
}
|
|
252
|
+
const lines = [
|
|
253
|
+
`${result.status} — ${result.host}${result.path}`,
|
|
254
|
+
` locale: ${result.locale}`,
|
|
255
|
+
` releaseId: ${result.releaseId}`,
|
|
256
|
+
` cachedAt: ${result.cachedAt}`,
|
|
257
|
+
` expiresAt: ${result.expiresAt}`,
|
|
258
|
+
` contentSha256: ${result.contentSha256}`,
|
|
259
|
+
];
|
|
260
|
+
if (result.writtenUnderGeneration) {
|
|
261
|
+
lines.push(` writtenUnderGen: ${result.writtenUnderGeneration}`);
|
|
262
|
+
}
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
}
|
package/lib/dev.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run402 dev — Run a local Astro dev server with Run402 context injected.
|
|
3
|
+
*
|
|
4
|
+
* Capability `astro-ssr-runtime` (Run402 v1.52). Wraps `astro dev` with
|
|
5
|
+
* Run402-specific environment + SDK proxy so the dev experience matches
|
|
6
|
+
* production:
|
|
7
|
+
*
|
|
8
|
+
* 1. Load .env.local (if present) for project credentials.
|
|
9
|
+
* 2. Confirm RUN402_PROJECT_ID + RUN402_SERVICE_KEY are set.
|
|
10
|
+
* 3. Spawn `astro dev` as a child process inheriting the env.
|
|
11
|
+
*
|
|
12
|
+
* Future enhancements (deferred):
|
|
13
|
+
* - DB/auth/storage/cache emulation via a local proxy.
|
|
14
|
+
* - AsyncLocalStorage middleware that populates the same context
|
|
15
|
+
* shape as the SSR Lambda runtime.
|
|
16
|
+
* - Visible cache.invalidate() events in the dev log.
|
|
17
|
+
*
|
|
18
|
+
* Until those land, `run402 dev` is a thin wrapper that ensures the
|
|
19
|
+
* env shape is right and the Astro dev server starts with Run402
|
|
20
|
+
* credentials in scope — SDK calls in frontmatter then hit the live
|
|
21
|
+
* project via @run402/functions's default API_BASE.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { fail } from "./sdk-errors.mjs";
|
|
28
|
+
|
|
29
|
+
const HELP = `run402 dev — Run Astro dev with Run402 context
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
run402 dev [--port <n>] [--host <h>] [--project <id>]
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--port <n> Astro dev port (default 4321)
|
|
36
|
+
--host <h> Astro dev host (default localhost)
|
|
37
|
+
--project <id> Project id (default: RUN402_PROJECT_ID env var)
|
|
38
|
+
|
|
39
|
+
The command:
|
|
40
|
+
1. Loads .env.local from the current directory (if present)
|
|
41
|
+
2. Verifies RUN402_PROJECT_ID and RUN402_SERVICE_KEY are set
|
|
42
|
+
3. Spawns 'astro dev' with the env inherited
|
|
43
|
+
|
|
44
|
+
SDK calls (db, getUser, cache.invalidate, assets.put) hit the LIVE
|
|
45
|
+
project at https://api.run402.com — no local DB / S3 / KMS setup needed.
|
|
46
|
+
This is the recommended dev model: shape-parity with production.
|
|
47
|
+
|
|
48
|
+
For offline dev with a local emulator, deferred to v1.5.
|
|
49
|
+
|
|
50
|
+
Tip: in your project's package.json:
|
|
51
|
+
{ "scripts": { "dev": "run402 dev" } }
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export async function run(sub, args = []) {
|
|
55
|
+
const all = [sub, ...args].filter(Boolean);
|
|
56
|
+
if (all.includes("--help") || all.includes("-h")) {
|
|
57
|
+
console.log(HELP);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Load .env.local (best effort — silently ignore if missing).
|
|
62
|
+
const envFile = resolve(process.cwd(), ".env.local");
|
|
63
|
+
if (existsSync(envFile)) {
|
|
64
|
+
const content = readFileSync(envFile, "utf-8");
|
|
65
|
+
for (const line of content.split("\n")) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
68
|
+
const eq = trimmed.indexOf("=");
|
|
69
|
+
if (eq === -1) continue;
|
|
70
|
+
const key = trimmed.slice(0, eq).trim();
|
|
71
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
72
|
+
// Strip optional surrounding quotes.
|
|
73
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
74
|
+
val = val.slice(1, -1);
|
|
75
|
+
}
|
|
76
|
+
if (!process.env[key]) process.env[key] = val;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const port = pickFlagValue(all, "--port") ?? "4321";
|
|
81
|
+
const host = pickFlagValue(all, "--host") ?? "localhost";
|
|
82
|
+
const projectId = pickFlagValue(all, "--project") ?? process.env.RUN402_PROJECT_ID;
|
|
83
|
+
|
|
84
|
+
if (!projectId) {
|
|
85
|
+
fail({
|
|
86
|
+
code: "BAD_USAGE",
|
|
87
|
+
message: "Missing RUN402_PROJECT_ID.",
|
|
88
|
+
hint: "Set it in .env.local (or pass --project <id>). Run 'run402 projects provision' first if you don't have one.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (!process.env.RUN402_SERVICE_KEY) {
|
|
92
|
+
fail({
|
|
93
|
+
code: "BAD_USAGE",
|
|
94
|
+
message: "Missing RUN402_SERVICE_KEY.",
|
|
95
|
+
hint: "Add it to .env.local. Get the service key from 'run402 projects list' or your Run402 dashboard.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
process.env.RUN402_PROJECT_ID = projectId;
|
|
100
|
+
|
|
101
|
+
console.log(`run402 dev — Astro on http://${host}:${port}`);
|
|
102
|
+
console.log(` project: ${projectId}`);
|
|
103
|
+
console.log(` api_base: ${process.env.RUN402_API_BASE ?? "https://api.run402.com"}`);
|
|
104
|
+
console.log(` env: .env.local ${existsSync(envFile) ? "loaded" : "not present (using process env)"}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
|
|
107
|
+
// Spawn `npx astro dev`. We use `npx` so the local astro install resolves
|
|
108
|
+
// even if it's not on PATH. The child inherits stdio so the user sees
|
|
109
|
+
// Astro's normal output.
|
|
110
|
+
const child = spawn("npx", ["astro", "dev", "--port", port, "--host", host], {
|
|
111
|
+
stdio: "inherit",
|
|
112
|
+
env: process.env,
|
|
113
|
+
shell: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Bubble up exit codes for clean tooling integration.
|
|
117
|
+
child.on("exit", (code, signal) => {
|
|
118
|
+
if (signal) {
|
|
119
|
+
process.kill(process.pid, signal);
|
|
120
|
+
} else {
|
|
121
|
+
process.exit(code ?? 0);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Forward SIGINT / SIGTERM to the child so Ctrl-C cleanly stops Astro.
|
|
126
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
127
|
+
process.on(sig, () => {
|
|
128
|
+
child.kill(sig);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pickFlagValue(args, flag) {
|
|
134
|
+
const idx = args.indexOf(flag);
|
|
135
|
+
if (idx === -1) return undefined;
|
|
136
|
+
return args[idx + 1];
|
|
137
|
+
}
|
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run402 doctor — Health and config diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* Reports the state of the local Run402 setup: config dir, allowance,
|
|
5
|
+
* tier, project selection, API reachability. Agent-friendly: with
|
|
6
|
+
* `--json`, emits a structured report the agent can branch on without
|
|
7
|
+
* parsing English output.
|
|
8
|
+
*
|
|
9
|
+
* Capability `astro-ssr-runtime` (Run402 v1.52). Part of the agent-DX
|
|
10
|
+
* contract — agents run `run402 doctor` first to verify the environment
|
|
11
|
+
* before attempting other commands.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, statSync } from "node:fs";
|
|
15
|
+
import { CONFIG_DIR, readAllowance, loadKeyStore } from "./config.mjs";
|
|
16
|
+
import { getSdk } from "./sdk.mjs";
|
|
17
|
+
|
|
18
|
+
const HELP = `run402 doctor — Health and config diagnostics
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
run402 doctor [--json] [--verbose]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--json Emit a structured JSON report on stdout
|
|
25
|
+
--verbose Include extra detail (timing, error messages)
|
|
26
|
+
|
|
27
|
+
Checks performed:
|
|
28
|
+
- Config directory exists and is writable
|
|
29
|
+
- Allowance is configured and on a valid rail (x402 / mpp)
|
|
30
|
+
- Keystore has at least one wallet
|
|
31
|
+
- API_BASE is reachable (network check via /health)
|
|
32
|
+
- Active tier resolves and is not 'past_due' / 'frozen'
|
|
33
|
+
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 — all checks pass
|
|
36
|
+
1 — one or more checks failed (details in output)
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export async function run(sub, args = []) {
|
|
40
|
+
const all = [sub, ...args].filter(Boolean);
|
|
41
|
+
if (all.includes("--help") || all.includes("-h")) {
|
|
42
|
+
console.log(HELP);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const json = all.includes("--json");
|
|
46
|
+
const verbose = all.includes("--verbose");
|
|
47
|
+
|
|
48
|
+
const checks = [];
|
|
49
|
+
|
|
50
|
+
// 1. Config directory.
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(CONFIG_DIR) && statSync(CONFIG_DIR).isDirectory()) {
|
|
53
|
+
checks.push({ name: "config_dir", status: "ok", value: CONFIG_DIR });
|
|
54
|
+
} else {
|
|
55
|
+
checks.push({
|
|
56
|
+
name: "config_dir",
|
|
57
|
+
status: "missing",
|
|
58
|
+
value: CONFIG_DIR,
|
|
59
|
+
hint: "Run 'run402 init' to set up the config directory.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
checks.push({
|
|
64
|
+
name: "config_dir",
|
|
65
|
+
status: "error",
|
|
66
|
+
message: err instanceof Error ? err.message : String(err),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Allowance.
|
|
71
|
+
try {
|
|
72
|
+
const allowance = readAllowance();
|
|
73
|
+
if (allowance) {
|
|
74
|
+
checks.push({
|
|
75
|
+
name: "allowance",
|
|
76
|
+
status: "ok",
|
|
77
|
+
value: {
|
|
78
|
+
rail: allowance.rail,
|
|
79
|
+
// Don't surface amounts or addresses unless --verbose; agents
|
|
80
|
+
// checking for config presence don't need wallet details.
|
|
81
|
+
...(verbose && { details: allowance }),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
checks.push({
|
|
86
|
+
name: "allowance",
|
|
87
|
+
status: "missing",
|
|
88
|
+
hint: "Run 'run402 init' to create an allowance.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
checks.push({
|
|
93
|
+
name: "allowance",
|
|
94
|
+
status: "error",
|
|
95
|
+
message: err instanceof Error ? err.message : String(err),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. Keystore.
|
|
100
|
+
try {
|
|
101
|
+
const keystore = loadKeyStore();
|
|
102
|
+
const walletCount = Object.keys(keystore?.wallets ?? {}).length;
|
|
103
|
+
checks.push({
|
|
104
|
+
name: "keystore",
|
|
105
|
+
status: walletCount > 0 ? "ok" : "empty",
|
|
106
|
+
value: { wallet_count: walletCount },
|
|
107
|
+
...(walletCount === 0 && {
|
|
108
|
+
hint: "Run 'run402 init' to generate a wallet.",
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
checks.push({
|
|
113
|
+
name: "keystore",
|
|
114
|
+
status: "error",
|
|
115
|
+
message: err instanceof Error ? err.message : String(err),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 4. API base reachability.
|
|
120
|
+
try {
|
|
121
|
+
const sdk = getSdk();
|
|
122
|
+
// Use the service.status endpoint (read-only, unauthenticated).
|
|
123
|
+
const t0 = Date.now();
|
|
124
|
+
await sdk.service.status();
|
|
125
|
+
const elapsed = Date.now() - t0;
|
|
126
|
+
checks.push({
|
|
127
|
+
name: "api_reachable",
|
|
128
|
+
status: "ok",
|
|
129
|
+
...(verbose && { value: { elapsed_ms: elapsed } }),
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
checks.push({
|
|
133
|
+
name: "api_reachable",
|
|
134
|
+
status: "error",
|
|
135
|
+
message: err instanceof Error ? err.message : String(err),
|
|
136
|
+
hint: "Check the RUN402_API_BASE env var and your network connection.",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 5. Active tier.
|
|
141
|
+
try {
|
|
142
|
+
const sdk = getSdk();
|
|
143
|
+
const tier = await sdk.tier.status();
|
|
144
|
+
const tierName = tier?.tier ?? null;
|
|
145
|
+
if (tierName && tierName !== "past_due" && tierName !== "frozen" && tierName !== "dormant") {
|
|
146
|
+
checks.push({
|
|
147
|
+
name: "tier",
|
|
148
|
+
status: "ok",
|
|
149
|
+
value: { tier: tierName },
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
checks.push({
|
|
153
|
+
name: "tier",
|
|
154
|
+
status: tierName ?? "missing",
|
|
155
|
+
...(tierName && {
|
|
156
|
+
hint: "Run 'run402 tier set prototype' to subscribe (or upgrade).",
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
checks.push({
|
|
162
|
+
name: "tier",
|
|
163
|
+
status: "error",
|
|
164
|
+
message: err instanceof Error ? err.message : String(err),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const allOk = checks.every((c) => c.status === "ok");
|
|
169
|
+
|
|
170
|
+
if (json) {
|
|
171
|
+
console.log(JSON.stringify({ ok: allOk, checks }, null, 2));
|
|
172
|
+
} else {
|
|
173
|
+
console.log(`Run402 doctor — ${allOk ? "all checks passed" : "issues found"}`);
|
|
174
|
+
console.log("");
|
|
175
|
+
for (const c of checks) {
|
|
176
|
+
const icon =
|
|
177
|
+
c.status === "ok" ? "✓"
|
|
178
|
+
: c.status === "missing" || c.status === "empty" ? "⚠"
|
|
179
|
+
: "✗";
|
|
180
|
+
const status = c.status === "ok" ? "ok" : c.status;
|
|
181
|
+
console.log(` ${icon} ${c.name.padEnd(16)} ${status}`);
|
|
182
|
+
if (c.hint) console.log(` → ${c.hint}`);
|
|
183
|
+
if (c.message) console.log(` ${c.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
process.exit(allOk ? 0 : 1);
|
|
188
|
+
}
|