@openparachute/app 0.2.0-rc.10
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/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +537 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +682 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +715 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +184 -0
- package/src/services-manifest.ts +104 -0
- package/src/tenancy-injection.ts +149 -0
- package/src/ui-registry.ts +202 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `parachute-app` CLI — Phase 1.2.
|
|
4
|
+
*
|
|
5
|
+
* App is the UI host module for custom Parachute UIs. It supervises a
|
|
6
|
+
* directory of pre-built static bundles (each with a `meta.json`) and
|
|
7
|
+
* serves them under the hub origin.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1.2 (this release): admin verbs (`add`/`remove`/`list`/`reload`)
|
|
10
|
+
* call the running daemon's HTTP admin endpoints. Operator must have
|
|
11
|
+
* `parachute-app serve` running locally; the CLI is a thin HTTP client
|
|
12
|
+
* over that, the same pattern hub's `parachute auth approve-client` etc.
|
|
13
|
+
* use. Dev mode (`dev`) lands in Phase 1.3.
|
|
14
|
+
*
|
|
15
|
+
* Design:
|
|
16
|
+
* parachute.computer/design/2026-05-21-parachute-apps-design.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import pkg from "../package.json" with { type: "json" };
|
|
20
|
+
import { DEFAULT_PORT, serve } from "../src/index.ts";
|
|
21
|
+
import { readOperatorToken } from "../src/operator-token.ts";
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const command = args[0];
|
|
25
|
+
|
|
26
|
+
function usage(): void {
|
|
27
|
+
console.log(`parachute-app — UI host module for custom Parachute UIs
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
parachute-app serve Start the daemon
|
|
31
|
+
parachute-app add <source> [flags] Register a new UI
|
|
32
|
+
<source>: local path OR npm spec (@scope/pkg[@version])
|
|
33
|
+
flags: --name <n> --path </app/n> [--display <d>]
|
|
34
|
+
[--scopes <s1,s2>] [--force]
|
|
35
|
+
parachute-app remove <name> Unregister a UI + revoke its OAuth client
|
|
36
|
+
parachute-app list List installed UIs with status + OAuth state
|
|
37
|
+
parachute-app reload <name> Refresh a UI's bundle (no daemon restart)
|
|
38
|
+
parachute-app provision-schema <name> Re-trigger required_schema auto-provisioning for <name>
|
|
39
|
+
parachute-app dev <name> Enable dev mode for <name> (no-cache + SSE reload)
|
|
40
|
+
parachute-app dev <name> --off Disable dev mode for <name>
|
|
41
|
+
parachute-app dev <name> --trigger Broadcast a reload event to connected tabs
|
|
42
|
+
parachute-app dev list List UIs currently in dev mode
|
|
43
|
+
parachute-app --help, -h Show this help
|
|
44
|
+
parachute-app --version, -v Print version and exit
|
|
45
|
+
|
|
46
|
+
Environment:
|
|
47
|
+
PARACHUTE_APP_URL Override the daemon URL (default http://127.0.0.1:1946).
|
|
48
|
+
PARACHUTE_HUB_TOKEN Operator bearer used for admin endpoint auth.
|
|
49
|
+
Falls back to ~/.parachute/operator.token.
|
|
50
|
+
|
|
51
|
+
\`serve\` behavior:
|
|
52
|
+
Reads $PARACHUTE_HOME/app/config.json (or built-in defaults).
|
|
53
|
+
Scans $PARACHUTE_HOME/app/uis/ for declared UIs; each subdir needs
|
|
54
|
+
a meta.json + dist/index.html. Mounts each UI at its declared path
|
|
55
|
+
under /app/<name>/. Admin endpoints + admin SPA under /app/admin/
|
|
56
|
+
are served by the same daemon.
|
|
57
|
+
|
|
58
|
+
\`dev\` behavior:
|
|
59
|
+
Dev mode is process-local — a daemon restart resets every UI to
|
|
60
|
+
production cache headers. While on, every response from the UI is
|
|
61
|
+
no-cache, no-store, must-revalidate (overrides the immutable default
|
|
62
|
+
for hashed assets); index.html gets a small EventSource shim that
|
|
63
|
+
reloads the tab on a \`--trigger\` event. Phase 3.0+: when the UI is
|
|
64
|
+
in dev mode, app watches the UI's source dir (meta.dev_watch_dir,
|
|
65
|
+
defaults to the UI's root dir minus dist/ + node_modules/) and auto-
|
|
66
|
+
broadcasts a reload on change. If meta.dev_build_cmd is set, app
|
|
67
|
+
runs that build first; failing builds log + skip the reload. The
|
|
68
|
+
manual --trigger still works as a fallback.
|
|
69
|
+
|
|
70
|
+
Design:
|
|
71
|
+
https://github.com/ParachuteComputer/parachute.computer/blob/main/design/2026-05-21-parachute-apps-design.md
|
|
72
|
+
`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function daemonUrl(): string {
|
|
76
|
+
const base = process.env.PARACHUTE_APP_URL?.replace(/\/$/, "");
|
|
77
|
+
if (base && base.length > 0) return base;
|
|
78
|
+
return `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Source the same operator bearer the daemon uses for outbound DCR calls. */
|
|
82
|
+
function bearerToken(): string | undefined {
|
|
83
|
+
return readOperatorToken();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Headers for an authenticated call to the daemon. */
|
|
87
|
+
function authHeaders(): Record<string, string> {
|
|
88
|
+
const t = bearerToken();
|
|
89
|
+
return t ? { authorization: `Bearer ${t}` } : {};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pretty-printer for the API responses. Keeps the surface human-skimmable
|
|
94
|
+
* without paying the wcwidth/table overhead of a "real" CLI library.
|
|
95
|
+
*/
|
|
96
|
+
function printJson(payload: unknown): void {
|
|
97
|
+
// Default to the compact pretty-print; operators wanting the raw JSON
|
|
98
|
+
// can pipe through `jq` against `curl`.
|
|
99
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function callDaemon(
|
|
103
|
+
method: "GET" | "POST" | "DELETE",
|
|
104
|
+
path: string,
|
|
105
|
+
body?: unknown,
|
|
106
|
+
): Promise<{ status: number; body: unknown }> {
|
|
107
|
+
const url = `${daemonUrl()}${path}`;
|
|
108
|
+
const init: RequestInit = {
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
112
|
+
...authHeaders(),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
if (body !== undefined) init.body = JSON.stringify(body);
|
|
116
|
+
|
|
117
|
+
let res: Response;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(url, init);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error(`Couldn't reach the daemon at ${url}: ${(e as Error).message}`);
|
|
122
|
+
console.error("Is `parachute-app serve` running?");
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
const text = await res.text();
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try {
|
|
128
|
+
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
129
|
+
} catch {
|
|
130
|
+
parsed = text;
|
|
131
|
+
}
|
|
132
|
+
return { status: res.status, body: parsed };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a `--flag value` style arg list into a key→value map. */
|
|
136
|
+
function parseFlags(rest: string[]): {
|
|
137
|
+
positionals: string[];
|
|
138
|
+
flags: Record<string, string | boolean>;
|
|
139
|
+
} {
|
|
140
|
+
const positionals: string[] = [];
|
|
141
|
+
const flags: Record<string, string | boolean> = {};
|
|
142
|
+
for (let i = 0; i < rest.length; i++) {
|
|
143
|
+
const a = rest[i]!;
|
|
144
|
+
if (a.startsWith("--")) {
|
|
145
|
+
const key = a.slice(2);
|
|
146
|
+
const next = rest[i + 1];
|
|
147
|
+
if (next && !next.startsWith("--")) {
|
|
148
|
+
flags[key] = next;
|
|
149
|
+
i++;
|
|
150
|
+
} else {
|
|
151
|
+
flags[key] = true;
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
positionals.push(a);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { positionals, flags };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runAdd(rest: string[]): Promise<void> {
|
|
161
|
+
const { positionals, flags } = parseFlags(rest);
|
|
162
|
+
if (positionals.length === 0) {
|
|
163
|
+
console.error("add: missing <source> (local path or npm spec)");
|
|
164
|
+
console.error("Run `parachute-app --help` for usage.");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
const body: Record<string, unknown> = { source: positionals[0] };
|
|
168
|
+
if (typeof flags.name === "string") body.name = flags.name;
|
|
169
|
+
if (typeof flags.path === "string") body.path = flags.path;
|
|
170
|
+
if (typeof flags.display === "string") body.displayName = flags.display;
|
|
171
|
+
if (typeof flags.tagline === "string") body.tagline = flags.tagline;
|
|
172
|
+
if (typeof flags.scopes === "string") {
|
|
173
|
+
body.scopes_required = flags.scopes
|
|
174
|
+
.split(",")
|
|
175
|
+
.map((s) => s.trim())
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
if (flags.force === true) body.force = true;
|
|
179
|
+
const { status, body: resBody } = await callDaemon("POST", "/app/add", body);
|
|
180
|
+
if (status >= 200 && status < 300) {
|
|
181
|
+
const r = resBody as {
|
|
182
|
+
ui?: { name?: string; path?: string };
|
|
183
|
+
oauth_client_id?: string;
|
|
184
|
+
oauth_status?: string;
|
|
185
|
+
warning?: string;
|
|
186
|
+
};
|
|
187
|
+
if (r.ui) {
|
|
188
|
+
console.log(`Added ${r.ui.name} at ${r.ui.path}`);
|
|
189
|
+
}
|
|
190
|
+
if (r.oauth_client_id) {
|
|
191
|
+
console.log(
|
|
192
|
+
` oauth client_id: ${r.oauth_client_id}${r.oauth_status ? ` (${r.oauth_status})` : ""}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (r.warning) {
|
|
196
|
+
console.log(` warning: ${r.warning}`);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
console.error(`add failed (HTTP ${status}):`);
|
|
201
|
+
printJson(resBody);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runRemove(rest: string[]): Promise<void> {
|
|
206
|
+
const name = rest[0];
|
|
207
|
+
if (!name) {
|
|
208
|
+
console.error("remove: missing <name>");
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
const { status, body } = await callDaemon("DELETE", `/app/${encodeURIComponent(name)}`);
|
|
212
|
+
if (status >= 200 && status < 300) {
|
|
213
|
+
console.log(`Removed ${name}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.error(`remove failed (HTTP ${status}):`);
|
|
217
|
+
printJson(body);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function runList(): Promise<void> {
|
|
222
|
+
const { status, body } = await callDaemon("GET", "/app/list");
|
|
223
|
+
if (status >= 200 && status < 300) {
|
|
224
|
+
const r = body as {
|
|
225
|
+
uis?: Array<{
|
|
226
|
+
name: string;
|
|
227
|
+
path: string;
|
|
228
|
+
displayName: string;
|
|
229
|
+
version?: string;
|
|
230
|
+
oauthClientId?: string;
|
|
231
|
+
}>;
|
|
232
|
+
skipped?: Array<{ dirName: string; status: string; reason: string }>;
|
|
233
|
+
};
|
|
234
|
+
const uis = r.uis ?? [];
|
|
235
|
+
if (uis.length === 0) {
|
|
236
|
+
console.log("(no UIs installed)");
|
|
237
|
+
} else {
|
|
238
|
+
for (const u of uis) {
|
|
239
|
+
const oauth = u.oauthClientId ? ` oauth=${u.oauthClientId}` : "";
|
|
240
|
+
const ver = u.version ? ` v${u.version}` : "";
|
|
241
|
+
console.log(` ${u.path} ${u.displayName} (${u.name})${ver}${oauth}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const skipped = r.skipped ?? [];
|
|
245
|
+
if (skipped.length > 0) {
|
|
246
|
+
console.log("");
|
|
247
|
+
console.log("Skipped:");
|
|
248
|
+
for (const s of skipped) {
|
|
249
|
+
console.log(` ${s.dirName} ${s.status}: ${s.reason}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
console.error(`list failed (HTTP ${status}):`);
|
|
255
|
+
printJson(body);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function runReload(rest: string[]): Promise<void> {
|
|
260
|
+
const name = rest[0];
|
|
261
|
+
if (!name) {
|
|
262
|
+
console.error("reload: missing <name>");
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
const { status, body } = await callDaemon("POST", `/app/${encodeURIComponent(name)}/reload`);
|
|
266
|
+
if (status >= 200 && status < 300) {
|
|
267
|
+
console.log(`Reloaded ${name}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.error(`reload failed (HTTP ${status}):`);
|
|
271
|
+
printJson(body);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Phase 2.1 `provision-schema` verb — re-trigger the required_schema
|
|
277
|
+
* auto-provisioner against the named UI's `vault_default`. Best-effort
|
|
278
|
+
* on the daemon side; we always print the per-tag summary the daemon
|
|
279
|
+
* returns. Exits with code 1 only when the endpoint itself errors out
|
|
280
|
+
* (auth, missing UI, etc.) — per-tag PUT failures still exit 0 because
|
|
281
|
+
* the daemon's response is structured (200 with errors[]).
|
|
282
|
+
*/
|
|
283
|
+
async function runProvisionSchema(rest: string[]): Promise<void> {
|
|
284
|
+
const name = rest[0];
|
|
285
|
+
if (!name) {
|
|
286
|
+
console.error("provision-schema: missing <name>");
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const { status, body } = await callDaemon(
|
|
290
|
+
"POST",
|
|
291
|
+
`/app/${encodeURIComponent(name)}/provision-schema`,
|
|
292
|
+
);
|
|
293
|
+
if (status >= 200 && status < 300) {
|
|
294
|
+
const r = body as {
|
|
295
|
+
ok: boolean;
|
|
296
|
+
provisioned?: string[];
|
|
297
|
+
errors?: Array<{ tag: string; error: string }>;
|
|
298
|
+
skipReason?: string;
|
|
299
|
+
vaultUrl?: string;
|
|
300
|
+
};
|
|
301
|
+
if (r.skipReason) {
|
|
302
|
+
console.log(`Skipped: ${r.skipReason}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (r.vaultUrl) console.log(`Vault: ${r.vaultUrl}`);
|
|
306
|
+
if (r.provisioned && r.provisioned.length > 0) {
|
|
307
|
+
console.log(`Provisioned ${r.provisioned.length} tag(s):`);
|
|
308
|
+
for (const t of r.provisioned) console.log(` ${t}`);
|
|
309
|
+
}
|
|
310
|
+
if (r.errors && r.errors.length > 0) {
|
|
311
|
+
console.log(`Failed (${r.errors.length}):`);
|
|
312
|
+
for (const e of r.errors) console.log(` ${e.tag}: ${e.error}`);
|
|
313
|
+
}
|
|
314
|
+
if ((!r.provisioned || r.provisioned.length === 0) && (!r.errors || r.errors.length === 0)) {
|
|
315
|
+
console.log("(no tags to provision)");
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
console.error(`provision-schema failed (HTTP ${status}):`);
|
|
320
|
+
printJson(body);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Phase 1.3 `dev` verb — enable / disable / trigger / list.
|
|
326
|
+
*
|
|
327
|
+
* Sub-shape (matches the design doc operator flow):
|
|
328
|
+
* parachute-app dev <name> # enable (idempotent)
|
|
329
|
+
* parachute-app dev <name> --off # disable
|
|
330
|
+
* parachute-app dev <name> --trigger # broadcast reload event
|
|
331
|
+
* parachute-app dev list # show UIs currently in dev mode
|
|
332
|
+
*/
|
|
333
|
+
async function runDev(rest: string[]): Promise<void> {
|
|
334
|
+
const { positionals, flags } = parseFlags(rest);
|
|
335
|
+
const sub = positionals[0];
|
|
336
|
+
|
|
337
|
+
// `dev list` (no other args).
|
|
338
|
+
if (sub === "list") {
|
|
339
|
+
const { status, body } = await callDaemon("GET", "/app/dev/list");
|
|
340
|
+
if (status >= 200 && status < 300) {
|
|
341
|
+
const r = body as {
|
|
342
|
+
uis?: Array<{
|
|
343
|
+
name: string;
|
|
344
|
+
enabled: boolean;
|
|
345
|
+
enabledAt: number;
|
|
346
|
+
subscribers: number;
|
|
347
|
+
watcher?: { watching: boolean; watchDir?: string; buildCmd?: string | null };
|
|
348
|
+
}>;
|
|
349
|
+
};
|
|
350
|
+
const uis = r.uis ?? [];
|
|
351
|
+
if (uis.length === 0) {
|
|
352
|
+
console.log("(no UIs in dev mode)");
|
|
353
|
+
} else {
|
|
354
|
+
for (const u of uis) {
|
|
355
|
+
const since = u.enabledAt > 0 ? new Date(u.enabledAt).toISOString() : "—";
|
|
356
|
+
console.log(
|
|
357
|
+
` ${u.name} enabled=${u.enabled} since=${since} subscribers=${u.subscribers}`,
|
|
358
|
+
);
|
|
359
|
+
if (u.watcher?.watching) {
|
|
360
|
+
const buildBadge = u.watcher.buildCmd
|
|
361
|
+
? ` build=\`${u.watcher.buildCmd}\``
|
|
362
|
+
: " (no build cmd)";
|
|
363
|
+
console.log(` watching=${u.watcher.watchDir}${buildBadge}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
console.error(`dev list failed (HTTP ${status}):`);
|
|
370
|
+
printJson(body);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!sub) {
|
|
375
|
+
console.error("dev: missing <name> (or `list`)");
|
|
376
|
+
console.error("Run `parachute-app --help` for usage.");
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const name = sub;
|
|
381
|
+
const off = flags.off === true;
|
|
382
|
+
const trigger = flags.trigger === true;
|
|
383
|
+
|
|
384
|
+
if (off && trigger) {
|
|
385
|
+
console.error("dev: --off and --trigger are mutually exclusive");
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (off) {
|
|
390
|
+
const { status, body } = await callDaemon(
|
|
391
|
+
"POST",
|
|
392
|
+
`/app/${encodeURIComponent(name)}/dev/disable`,
|
|
393
|
+
);
|
|
394
|
+
if (status >= 200 && status < 300) {
|
|
395
|
+
const r = body as { was_on?: boolean };
|
|
396
|
+
console.log(`Dev mode OFF for ${name}${r.was_on === false ? " (was already off)" : ""}`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
console.error(`dev --off failed (HTTP ${status}):`);
|
|
400
|
+
printJson(body);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (trigger) {
|
|
405
|
+
const { status, body } = await callDaemon(
|
|
406
|
+
"POST",
|
|
407
|
+
`/app/${encodeURIComponent(name)}/dev/trigger`,
|
|
408
|
+
);
|
|
409
|
+
if (status >= 200 && status < 300) {
|
|
410
|
+
const r = body as { notified?: number };
|
|
411
|
+
console.log(`Reload broadcast for ${name}: notified ${r.notified ?? 0} client(s)`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
console.error(`dev --trigger failed (HTTP ${status}):`);
|
|
415
|
+
printJson(body);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Default sub-verb: enable dev mode.
|
|
420
|
+
const { status, body } = await callDaemon("POST", `/app/${encodeURIComponent(name)}/dev/enable`);
|
|
421
|
+
if (status >= 200 && status < 300) {
|
|
422
|
+
const r = body as {
|
|
423
|
+
watcher?:
|
|
424
|
+
| { watching: true; watchDir: string; debounceMs: number; buildCmd?: string | null }
|
|
425
|
+
| { watching: false; warning?: string };
|
|
426
|
+
};
|
|
427
|
+
console.log(`Dev mode ON for ${name}`);
|
|
428
|
+
if (r.watcher?.watching) {
|
|
429
|
+
console.log(` Watching: ${r.watcher.watchDir} (debounce=${r.watcher.debounceMs}ms)`);
|
|
430
|
+
if (r.watcher.buildCmd) {
|
|
431
|
+
console.log(` Build cmd: \`${r.watcher.buildCmd}\` — runs on file change`);
|
|
432
|
+
console.log(" Edit a source file; the build runs, then the tab reloads.");
|
|
433
|
+
} else {
|
|
434
|
+
console.log(" Edit + build manually; the tab reloads when the watcher fires.");
|
|
435
|
+
console.log(" (Set meta.dev_build_cmd to skip the manual build step.)");
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
console.log(
|
|
439
|
+
` File watcher unavailable${r.watcher?.warning ? `: ${r.watcher.warning}` : ""}`,
|
|
440
|
+
);
|
|
441
|
+
console.log(" Edit, build, then run:");
|
|
442
|
+
console.log(` parachute-app dev ${name} --trigger`);
|
|
443
|
+
}
|
|
444
|
+
console.log(` parachute-app dev ${name} --off # when done`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
console.error(`dev failed (HTTP ${status}):`);
|
|
448
|
+
printJson(body);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function runServe(): Promise<void> {
|
|
453
|
+
const handle = serve();
|
|
454
|
+
// Wire signals so SIGINT/SIGTERM gracefully drain.
|
|
455
|
+
const onSignal = async (sig: NodeJS.Signals) => {
|
|
456
|
+
console.log(`[app] received ${sig}; stopping`);
|
|
457
|
+
try {
|
|
458
|
+
await handle.stop();
|
|
459
|
+
} catch (e) {
|
|
460
|
+
console.error(`[app] error during shutdown: ${(e as Error).message}`);
|
|
461
|
+
}
|
|
462
|
+
process.exit(0);
|
|
463
|
+
};
|
|
464
|
+
process.on("SIGINT", () => void onSignal("SIGINT"));
|
|
465
|
+
process.on("SIGTERM", () => void onSignal("SIGTERM"));
|
|
466
|
+
// Hold the event loop until a signal arrives. The HTTP server keeps the
|
|
467
|
+
// loop alive on its own, but we await a never-resolving promise as
|
|
468
|
+
// belt-and-braces — if the server crashes silently we want the process
|
|
469
|
+
// to stay up long enough for the supervisor to notice via /healthz.
|
|
470
|
+
await new Promise<void>(() => {
|
|
471
|
+
// intentionally never resolves
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function main(): Promise<void> {
|
|
476
|
+
const rest = args.slice(1);
|
|
477
|
+
switch (command) {
|
|
478
|
+
case "--version":
|
|
479
|
+
case "-v":
|
|
480
|
+
console.log(pkg.version);
|
|
481
|
+
return;
|
|
482
|
+
|
|
483
|
+
case "help":
|
|
484
|
+
case "--help":
|
|
485
|
+
case "-h":
|
|
486
|
+
case undefined:
|
|
487
|
+
usage();
|
|
488
|
+
return;
|
|
489
|
+
|
|
490
|
+
case "serve":
|
|
491
|
+
await runServe();
|
|
492
|
+
return;
|
|
493
|
+
|
|
494
|
+
case "add":
|
|
495
|
+
await runAdd(rest);
|
|
496
|
+
return;
|
|
497
|
+
|
|
498
|
+
case "remove":
|
|
499
|
+
await runRemove(rest);
|
|
500
|
+
return;
|
|
501
|
+
|
|
502
|
+
case "list":
|
|
503
|
+
await runList();
|
|
504
|
+
return;
|
|
505
|
+
|
|
506
|
+
case "reload":
|
|
507
|
+
await runReload(rest);
|
|
508
|
+
return;
|
|
509
|
+
|
|
510
|
+
case "provision-schema":
|
|
511
|
+
await runProvisionSchema(rest);
|
|
512
|
+
return;
|
|
513
|
+
|
|
514
|
+
case "dev":
|
|
515
|
+
await runDev(rest);
|
|
516
|
+
return;
|
|
517
|
+
|
|
518
|
+
default:
|
|
519
|
+
console.error(`Unknown command: ${command}`);
|
|
520
|
+
console.error("Run `parachute-app --help` for usage.");
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
await main();
|