@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.
@@ -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();