@openparachute/hub 0.3.0-rc.1

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.
Files changed (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
package/src/hub.ts ADDED
@@ -0,0 +1,674 @@
1
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFIG_DIR } from "./config.ts";
4
+
5
+ /**
6
+ * Hub page served at `/` when the node is exposed. Lists every installed
7
+ * service via a client-side fetch to `/.well-known/parachute.json` and each
8
+ * service's own `/.parachute/info`. Everything that needs personalization
9
+ * (name, tagline, icon) comes from the service, not the CLI — adding a new
10
+ * frontend requires zero hub-page changes.
11
+ *
12
+ * Card kinds (from `info.kind`, optional):
13
+ * "frontend" | undefined → whole card is an <a> that navigates to svc.url
14
+ * "api" | "tool" → card is non-navigating; click toggles a detail
15
+ * panel with OAuth/MCP/open-in-Notes links, so
16
+ * API-only services don't dead-end on raw JSON.
17
+ *
18
+ * The file is fully self-contained (inline CSS + JS, no external assets)
19
+ * so `tailscale serve` can mount it directly from disk with `--set-path=/`.
20
+ */
21
+
22
+ export const HUB_PATH = join(CONFIG_DIR, "well-known", "hub.html");
23
+ export const HUB_MOUNT = "/";
24
+
25
+ export function renderHub(): string {
26
+ return HTML;
27
+ }
28
+
29
+ export function writeHubFile(path: string = HUB_PATH): string {
30
+ if (!existsSync(dirname(path))) {
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ }
33
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
34
+ writeFileSync(tmp, HTML);
35
+ renameSync(tmp, path);
36
+ return path;
37
+ }
38
+
39
+ const HTML = `<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <title>Parachute</title>
45
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E\u{1FA82}%3C/text%3E%3C/svg%3E" />
46
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
47
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
48
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;600&display=swap" />
49
+ <style>
50
+ :root {
51
+ --bg: #faf8f4;
52
+ --bg-soft: #f3f0ea;
53
+ --fg: #2c2a26;
54
+ --fg-muted: #6b6860;
55
+ --fg-dim: #9a9690;
56
+ --accent: #4a7c59;
57
+ --accent-soft: rgba(74, 124, 89, 0.08);
58
+ --accent-hover: #3d6849;
59
+ --accent-light: #6a9b77;
60
+ --border: #e4e0d8;
61
+ --card-bg: #ffffff;
62
+ --serif: 'Instrument Serif', Georgia, serif;
63
+ --sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
64
+ }
65
+ @media (prefers-color-scheme: dark) {
66
+ :root {
67
+ --bg: #1a1917;
68
+ --bg-soft: #24221f;
69
+ --fg: #e8e4dc;
70
+ --fg-muted: #a8a49a;
71
+ --fg-dim: #6b6860;
72
+ --accent: #7ab08a;
73
+ --accent-soft: rgba(122, 176, 138, 0.1);
74
+ --accent-hover: #8fc49e;
75
+ --accent-light: #8fc49e;
76
+ --border: #3a3733;
77
+ --card-bg: #24221f;
78
+ }
79
+ }
80
+ * { box-sizing: border-box; }
81
+ html, body { margin: 0; padding: 0; }
82
+ body {
83
+ background: var(--bg);
84
+ color: var(--fg);
85
+ font-family: var(--sans);
86
+ line-height: 1.5;
87
+ -webkit-font-smoothing: antialiased;
88
+ min-height: 100vh;
89
+ }
90
+ main {
91
+ max-width: 960px;
92
+ margin: 0 auto;
93
+ padding: 4rem 1.5rem 6rem;
94
+ }
95
+ header {
96
+ text-align: center;
97
+ margin-bottom: 3.5rem;
98
+ }
99
+ h1 {
100
+ font-family: var(--serif);
101
+ font-weight: 400;
102
+ font-size: clamp(2.75rem, 6vw, 4rem);
103
+ line-height: 1.05;
104
+ margin: 0 0 0.75rem;
105
+ letter-spacing: -0.01em;
106
+ }
107
+ .tagline {
108
+ color: var(--fg-muted);
109
+ font-size: 1.1rem;
110
+ margin: 0;
111
+ }
112
+ .grid {
113
+ display: grid;
114
+ gap: 1.25rem;
115
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
116
+ }
117
+ .card {
118
+ background: var(--card-bg);
119
+ border: 1px solid var(--border);
120
+ border-radius: 12px;
121
+ padding: 1.75rem;
122
+ text-decoration: none;
123
+ color: inherit;
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 0.75rem;
127
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
128
+ opacity: 0;
129
+ animation: fadeUp 0.4s ease forwards;
130
+ }
131
+ .card:nth-child(1) { animation-delay: 0.02s; }
132
+ .card:nth-child(2) { animation-delay: 0.06s; }
133
+ .card:nth-child(3) { animation-delay: 0.1s; }
134
+ .card:nth-child(4) { animation-delay: 0.14s; }
135
+ .card:nth-child(5) { animation-delay: 0.18s; }
136
+ .card:nth-child(n+6) { animation-delay: 0.22s; }
137
+ .card:hover {
138
+ border-color: var(--accent-light);
139
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
140
+ transform: translateY(-2px);
141
+ }
142
+ .card-head {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 0.75rem;
146
+ }
147
+ .icon {
148
+ width: 2.25rem;
149
+ height: 2.25rem;
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ background: var(--accent-soft);
154
+ border-radius: 8px;
155
+ color: var(--accent);
156
+ font-size: 1.25rem;
157
+ flex-shrink: 0;
158
+ overflow: hidden;
159
+ }
160
+ .icon img, .icon svg {
161
+ width: 100%;
162
+ height: 100%;
163
+ object-fit: contain;
164
+ }
165
+ .card-title {
166
+ font-family: var(--serif);
167
+ font-size: 1.5rem;
168
+ font-weight: 400;
169
+ margin: 0;
170
+ line-height: 1.1;
171
+ }
172
+ .card-tagline {
173
+ color: var(--fg-muted);
174
+ font-size: 0.95rem;
175
+ margin: 0;
176
+ flex-grow: 1;
177
+ }
178
+ .card-meta {
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: space-between;
182
+ margin-top: 0.25rem;
183
+ font-size: 0.8rem;
184
+ color: var(--fg-dim);
185
+ }
186
+ .version {
187
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
188
+ padding: 0.1rem 0.5rem;
189
+ background: var(--bg-soft);
190
+ border-radius: 999px;
191
+ border: 1px solid var(--border);
192
+ }
193
+ .path {
194
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
195
+ color: var(--fg-muted);
196
+ }
197
+ .kind-badge {
198
+ font-size: 0.7rem;
199
+ letter-spacing: 0.04em;
200
+ text-transform: uppercase;
201
+ color: var(--fg-dim);
202
+ padding: 0.1rem 0.45rem;
203
+ border-radius: 999px;
204
+ border: 1px solid var(--border);
205
+ }
206
+ .card.interactive { cursor: pointer; }
207
+ .card.interactive:focus-visible {
208
+ outline: 2px solid var(--accent);
209
+ outline-offset: 2px;
210
+ }
211
+ .details {
212
+ display: none;
213
+ flex-direction: column;
214
+ gap: 0.5rem;
215
+ margin-top: 0.75rem;
216
+ padding-top: 0.75rem;
217
+ border-top: 1px dashed var(--border);
218
+ font-size: 0.9rem;
219
+ }
220
+ .card.expanded .details { display: flex; }
221
+ .details a {
222
+ color: var(--accent);
223
+ text-decoration: none;
224
+ border-bottom: 1px solid transparent;
225
+ transition: border-color 0.15s ease;
226
+ word-break: break-all;
227
+ }
228
+ .details a:hover { border-bottom-color: var(--accent-light); }
229
+ .details .row {
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: 0.15rem;
233
+ }
234
+ .details .row .label {
235
+ font-size: 0.75rem;
236
+ color: var(--fg-dim);
237
+ text-transform: uppercase;
238
+ letter-spacing: 0.04em;
239
+ }
240
+ .config {
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: 0.6rem;
244
+ margin-top: 0.5rem;
245
+ padding-top: 0.6rem;
246
+ border-top: 1px dashed var(--border);
247
+ }
248
+ .config h3 {
249
+ font-family: var(--serif);
250
+ font-size: 1.05rem;
251
+ font-weight: 400;
252
+ margin: 0;
253
+ }
254
+ .config .hint {
255
+ color: var(--fg-dim);
256
+ font-size: 0.78rem;
257
+ font-style: italic;
258
+ }
259
+ .config fieldset {
260
+ border: 1px solid var(--border);
261
+ border-radius: 8px;
262
+ padding: 0.6rem 0.8rem;
263
+ margin: 0;
264
+ display: flex;
265
+ flex-direction: column;
266
+ gap: 0.5rem;
267
+ }
268
+ .config legend {
269
+ padding: 0 0.35rem;
270
+ font-size: 0.75rem;
271
+ color: var(--fg-dim);
272
+ text-transform: uppercase;
273
+ letter-spacing: 0.04em;
274
+ }
275
+ .config .field {
276
+ display: flex;
277
+ flex-direction: column;
278
+ gap: 0.15rem;
279
+ }
280
+ .config .field-label {
281
+ font-size: 0.82rem;
282
+ color: var(--fg-muted);
283
+ font-weight: 500;
284
+ }
285
+ .config .field-description {
286
+ font-size: 0.75rem;
287
+ color: var(--fg-dim);
288
+ }
289
+ .config input,
290
+ .config select,
291
+ .config textarea {
292
+ font-family: var(--sans);
293
+ font-size: 0.88rem;
294
+ padding: 0.35rem 0.5rem;
295
+ background: var(--bg-soft);
296
+ color: var(--fg);
297
+ border: 1px solid var(--border);
298
+ border-radius: 6px;
299
+ opacity: 0.85;
300
+ cursor: not-allowed;
301
+ }
302
+ .config input[type="checkbox"] {
303
+ width: 1rem;
304
+ height: 1rem;
305
+ padding: 0;
306
+ }
307
+ .empty, .error {
308
+ text-align: center;
309
+ color: var(--fg-muted);
310
+ padding: 3rem 1rem;
311
+ border: 1px dashed var(--border);
312
+ border-radius: 12px;
313
+ }
314
+ .empty code, .error code {
315
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
316
+ background: var(--bg-soft);
317
+ padding: 0.1rem 0.4rem;
318
+ border-radius: 4px;
319
+ color: var(--accent);
320
+ }
321
+ footer {
322
+ text-align: center;
323
+ margin-top: 4rem;
324
+ color: var(--fg-dim);
325
+ font-size: 0.85rem;
326
+ }
327
+ footer a {
328
+ color: var(--fg-muted);
329
+ text-decoration: none;
330
+ border-bottom: 1px solid var(--border);
331
+ }
332
+ footer a:hover {
333
+ color: var(--accent);
334
+ border-bottom-color: var(--accent-light);
335
+ }
336
+ @keyframes fadeUp {
337
+ from { opacity: 0; transform: translateY(8px); }
338
+ to { opacity: 1; transform: translateY(0); }
339
+ }
340
+ @media (max-width: 640px) {
341
+ main { padding: 2.5rem 1rem 4rem; }
342
+ .card { padding: 1.5rem; }
343
+ }
344
+ </style>
345
+ </head>
346
+ <body>
347
+ <main>
348
+ <header>
349
+ <h1>Parachute</h1>
350
+ <p class="tagline">Your personal-computing services.</p>
351
+ </header>
352
+ <section id="services" class="grid" aria-live="polite">
353
+ <div class="empty" id="loading">Loading services\u2026</div>
354
+ </section>
355
+ <footer>
356
+ <a href="/.well-known/parachute.json">discovery</a>
357
+ </footer>
358
+ </main>
359
+ <script>
360
+ (async () => {
361
+ const root = document.getElementById('services');
362
+ const fallbackIcon = \`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/></svg>\`;
363
+
364
+ function shortName(manifestName) {
365
+ return manifestName.replace(/^parachute-/, '');
366
+ }
367
+
368
+ function fetchWithTimeout(url, ms) {
369
+ const ctl = new AbortController();
370
+ const t = setTimeout(() => ctl.abort(), ms);
371
+ return fetch(url, { signal: ctl.signal, credentials: 'omit' })
372
+ .finally(() => clearTimeout(t));
373
+ }
374
+
375
+ async function loadInfo(infoUrl) {
376
+ try {
377
+ const r = await fetchWithTimeout(infoUrl, 2000);
378
+ if (!r.ok) return null;
379
+ return await r.json();
380
+ } catch { return null; }
381
+ }
382
+
383
+ function isInteractiveKind(kind) {
384
+ return kind === 'api' || kind === 'tool';
385
+ }
386
+
387
+ function appendDetailRow(parent, label, node) {
388
+ const row = document.createElement('div');
389
+ row.className = 'row';
390
+ const lab = document.createElement('span');
391
+ lab.className = 'label';
392
+ lab.textContent = label;
393
+ row.appendChild(lab);
394
+ row.appendChild(node);
395
+ parent.appendChild(row);
396
+ }
397
+
398
+ function linkNode(href, text) {
399
+ const a = document.createElement('a');
400
+ a.href = href;
401
+ a.textContent = text || href;
402
+ a.target = '_blank';
403
+ a.rel = 'noopener';
404
+ return a;
405
+ }
406
+
407
+ function renderDetails(svc, info) {
408
+ const box = document.createElement('div');
409
+ box.className = 'details';
410
+ // OAuth discovery is served by the hub (this origin) per the Phase 0 seam.
411
+ appendDetailRow(
412
+ box,
413
+ 'OAuth discovery',
414
+ linkNode('/.well-known/oauth-authorization-server'),
415
+ );
416
+ if (info && typeof info.mcpUrl === 'string' && info.mcpUrl) {
417
+ appendDetailRow(box, 'MCP endpoint', linkNode(info.mcpUrl));
418
+ }
419
+ if (info && typeof info.openInNotesUrl === 'string' && info.openInNotesUrl) {
420
+ appendDetailRow(box, 'Open in Notes', linkNode(info.openInNotesUrl, 'Open →'));
421
+ }
422
+ // Direct URL is still useful for power users even if the card doesn't navigate.
423
+ appendDetailRow(box, 'Service URL', linkNode(svc.url));
424
+ // Empty slot — config fetched + populated lazily on first expand.
425
+ const configSlot = document.createElement('div');
426
+ configSlot.className = 'config-slot';
427
+ box.appendChild(configSlot);
428
+ return { box, configSlot };
429
+ }
430
+
431
+ async function fetchConfig(svcUrl) {
432
+ // Schema endpoint may 404 for modules that haven't shipped config yet;
433
+ // in that case we render nothing (no error surfaced).
434
+ const schemaResp = await fetchWithTimeout(
435
+ svcUrl.replace(/\\/+$/, '') + '/.parachute/config/schema',
436
+ 2000,
437
+ ).catch(() => null);
438
+ if (!schemaResp || !schemaResp.ok) return null;
439
+ const schema = await schemaResp.json().catch(() => null);
440
+ if (!schema || typeof schema !== 'object') return null;
441
+ const valuesResp = await fetchWithTimeout(
442
+ svcUrl.replace(/\\/+$/, '') + '/.parachute/config',
443
+ 2000,
444
+ ).catch(() => null);
445
+ const values = valuesResp && valuesResp.ok ? await valuesResp.json().catch(() => ({})) : {};
446
+ return { schema, values: values && typeof values === 'object' ? values : {} };
447
+ }
448
+
449
+ function labelFor(name, schema) {
450
+ return (schema && typeof schema.title === 'string' && schema.title) || name;
451
+ }
452
+
453
+ function renderConfigField(name, schema, value) {
454
+ const field = document.createElement('div');
455
+ field.className = 'field';
456
+
457
+ const label = document.createElement('span');
458
+ label.className = 'field-label';
459
+ label.textContent = labelFor(name, schema);
460
+ field.appendChild(label);
461
+
462
+ const type = schema && typeof schema.type === 'string' ? schema.type : 'string';
463
+ const writeOnly = schema && schema.writeOnly === true;
464
+
465
+ let input;
466
+ if (type === 'string' && Array.isArray(schema.enum)) {
467
+ input = document.createElement('select');
468
+ for (const opt of schema.enum) {
469
+ const o = document.createElement('option');
470
+ o.value = String(opt);
471
+ o.textContent = String(opt);
472
+ if (String(opt) === String(value ?? '')) o.selected = true;
473
+ input.appendChild(o);
474
+ }
475
+ } else if (type === 'boolean') {
476
+ input = document.createElement('input');
477
+ input.type = 'checkbox';
478
+ input.checked = value === true;
479
+ } else if (type === 'integer' || type === 'number') {
480
+ input = document.createElement('input');
481
+ input.type = 'number';
482
+ if (value !== undefined && value !== null) input.value = String(value);
483
+ } else {
484
+ input = document.createElement('input');
485
+ input.type = schema && schema.format === 'uri' ? 'url' : 'text';
486
+ if (writeOnly) {
487
+ input.placeholder = '\u2022\u2022\u2022\u2022\u2022\u2022';
488
+ input.value = '';
489
+ } else if (value !== undefined && value !== null) {
490
+ input.value = String(value);
491
+ }
492
+ }
493
+ input.disabled = true;
494
+ input.setAttribute('aria-readonly', 'true');
495
+ field.appendChild(input);
496
+
497
+ if (schema && typeof schema.description === 'string' && schema.description) {
498
+ const desc = document.createElement('span');
499
+ desc.className = 'field-description';
500
+ desc.textContent = schema.description;
501
+ field.appendChild(desc);
502
+ }
503
+
504
+ return field;
505
+ }
506
+
507
+ function renderConfigObject(schema, values, legendText) {
508
+ const fs = document.createElement('fieldset');
509
+ if (legendText) {
510
+ const lg = document.createElement('legend');
511
+ lg.textContent = legendText;
512
+ fs.appendChild(lg);
513
+ }
514
+ const props =
515
+ schema && typeof schema.properties === 'object' && schema.properties ? schema.properties : {};
516
+ for (const [name, propSchema] of Object.entries(props)) {
517
+ const v = values && typeof values === 'object' ? values[name] : undefined;
518
+ if (propSchema && propSchema.type === 'object') {
519
+ fs.appendChild(renderConfigObject(propSchema, v || {}, labelFor(name, propSchema)));
520
+ } else if (propSchema && propSchema.type === 'array') {
521
+ // Phase 2: arrays render as a disabled textarea summary; add/remove is Phase 3.
522
+ const f = document.createElement('div');
523
+ f.className = 'field';
524
+ const label = document.createElement('span');
525
+ label.className = 'field-label';
526
+ label.textContent = labelFor(name, propSchema);
527
+ f.appendChild(label);
528
+ const ta = document.createElement('textarea');
529
+ ta.rows = 2;
530
+ ta.value = Array.isArray(v) ? v.join('\\n') : '';
531
+ ta.disabled = true;
532
+ ta.setAttribute('aria-readonly', 'true');
533
+ f.appendChild(ta);
534
+ fs.appendChild(f);
535
+ } else {
536
+ fs.appendChild(renderConfigField(name, propSchema, v));
537
+ }
538
+ }
539
+ return fs;
540
+ }
541
+
542
+ function renderConfigBody(schema, values) {
543
+ const wrap = document.createElement('div');
544
+ wrap.className = 'config';
545
+ const title = document.createElement('h3');
546
+ title.textContent = (schema && schema.title) || 'Configuration';
547
+ wrap.appendChild(title);
548
+ const hint = document.createElement('span');
549
+ hint.className = 'hint';
550
+ hint.textContent =
551
+ 'Configuration is read-only in this launch — edit via CLI or ~/.parachute/<svc>/.env.';
552
+ wrap.appendChild(hint);
553
+ wrap.appendChild(renderConfigObject(schema, values, null));
554
+ return wrap;
555
+ }
556
+
557
+ function renderCard(svc, info) {
558
+ const kind = info && typeof info.kind === 'string' ? info.kind : 'frontend';
559
+ const interactive = isInteractiveKind(kind);
560
+ const root = document.createElement(interactive ? 'div' : 'a');
561
+ root.className = 'card' + (interactive ? ' interactive' : '');
562
+ if (!interactive) root.href = svc.url;
563
+ let configSlotRef = null;
564
+ let configLoaded = false;
565
+ if (interactive) {
566
+ root.setAttribute('role', 'button');
567
+ root.setAttribute('tabindex', '0');
568
+ root.setAttribute('aria-expanded', 'false');
569
+ const toggle = async () => {
570
+ const next = !root.classList.contains('expanded');
571
+ root.classList.toggle('expanded', next);
572
+ root.setAttribute('aria-expanded', next ? 'true' : 'false');
573
+ if (next && !configLoaded && configSlotRef) {
574
+ configLoaded = true;
575
+ const data = await fetchConfig(svc.url);
576
+ if (data) configSlotRef.appendChild(renderConfigBody(data.schema, data.values));
577
+ }
578
+ };
579
+ root.addEventListener('click', (e) => {
580
+ if (e.target && e.target.closest && e.target.closest('.details')) return;
581
+ toggle();
582
+ });
583
+ root.addEventListener('keydown', (e) => {
584
+ if (e.key === 'Enter' || e.key === ' ') {
585
+ e.preventDefault();
586
+ toggle();
587
+ }
588
+ });
589
+ }
590
+
591
+ const head = document.createElement('div');
592
+ head.className = 'card-head';
593
+
594
+ const icon = document.createElement('div');
595
+ icon.className = 'icon';
596
+ const iconUrl = info && info.icon ? new URL(info.icon, svc.url).toString() : null;
597
+ if (iconUrl) {
598
+ const img = document.createElement('img');
599
+ img.src = iconUrl;
600
+ img.alt = '';
601
+ img.onerror = () => { icon.innerHTML = fallbackIcon; };
602
+ icon.appendChild(img);
603
+ } else {
604
+ icon.innerHTML = fallbackIcon;
605
+ }
606
+
607
+ const title = document.createElement('h2');
608
+ title.className = 'card-title';
609
+ title.textContent = (info && info.displayName) || shortName(svc.name);
610
+
611
+ head.appendChild(icon);
612
+ head.appendChild(title);
613
+ root.appendChild(head);
614
+
615
+ const tag = info && info.tagline;
616
+ if (tag) {
617
+ const p = document.createElement('p');
618
+ p.className = 'card-tagline';
619
+ p.textContent = tag;
620
+ root.appendChild(p);
621
+ }
622
+
623
+ const meta = document.createElement('div');
624
+ meta.className = 'card-meta';
625
+ const path = document.createElement('span');
626
+ path.className = 'path';
627
+ path.textContent = svc.path;
628
+ const right = document.createElement('span');
629
+ right.style.display = 'inline-flex';
630
+ right.style.gap = '0.35rem';
631
+ right.style.alignItems = 'center';
632
+ if (interactive) {
633
+ const badge = document.createElement('span');
634
+ badge.className = 'kind-badge';
635
+ badge.textContent = kind;
636
+ right.appendChild(badge);
637
+ }
638
+ const ver = document.createElement('span');
639
+ ver.className = 'version';
640
+ ver.textContent = 'v' + svc.version;
641
+ right.appendChild(ver);
642
+ meta.appendChild(path);
643
+ meta.appendChild(right);
644
+ root.appendChild(meta);
645
+
646
+ if (interactive) {
647
+ const d = renderDetails(svc, info);
648
+ configSlotRef = d.configSlot;
649
+ root.appendChild(d.box);
650
+ }
651
+
652
+ return root;
653
+ }
654
+
655
+ try {
656
+ const wk = await fetch('/.well-known/parachute.json', { credentials: 'omit' });
657
+ if (!wk.ok) throw new Error('well-known fetch failed: ' + wk.status);
658
+ const doc = await wk.json();
659
+ const services = Array.isArray(doc.services) ? doc.services : [];
660
+ if (services.length === 0) {
661
+ root.innerHTML = '<div class="empty">No services installed yet. Try <code>parachute install vault</code>.</div>';
662
+ return;
663
+ }
664
+ const infos = await Promise.all(services.map((s) => loadInfo(s.infoUrl)));
665
+ root.innerHTML = '';
666
+ services.forEach((svc, i) => root.appendChild(renderCard(svc, infos[i])));
667
+ } catch (err) {
668
+ root.innerHTML = '<div class="error">Could not load services: ' + (err && err.message ? err.message : String(err)) + '</div>';
669
+ }
670
+ })();
671
+ </script>
672
+ </body>
673
+ </html>
674
+ `;