aios-dashboard 0.2.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.
@@ -0,0 +1,698 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ chmod,
5
+ cp,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ rename,
12
+ rm,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { createInterface } from "node:readline/promises";
18
+
19
+ import { installerEnv, mergeEnv, redactDatabaseUrl } from "./env.mjs";
20
+ import {
21
+ dashboardStatus,
22
+ openDashboard,
23
+ resolveDashboardPort,
24
+ startDashboard,
25
+ stopDashboard,
26
+ unmanagedDashboardReachable,
27
+ } from "./lifecycle.mjs";
28
+ import { dashboardPath, portableCommand, workspacePath } from "./paths.mjs";
29
+ import {
30
+ choosePackageManager,
31
+ databaseReachable,
32
+ findExecutable,
33
+ missingNativeBuildTools,
34
+ MINIMUM_NODE_VERSION,
35
+ supportsNode,
36
+ workspaceDatabaseUrl,
37
+ workspacePresent,
38
+ } from "./prerequisites.mjs";
39
+ import { downloadSource, normalizeSha256, redactSource } from "./source.mjs";
40
+ import { extractZip, findSourceRoot } from "./zip.mjs";
41
+
42
+ export const MEMBER_OWNED_PATHS = ["data", "learnings.md"];
43
+
44
+ export function parseArgs(argv) {
45
+ const options = {
46
+ command: null,
47
+ dir: null,
48
+ ref: null,
49
+ source: null,
50
+ sourceSha256: null,
51
+ start: false,
52
+ dryRun: false,
53
+ yes: false,
54
+ help: false,
55
+ // `connect`
56
+ port: null,
57
+ tunnelUrl: null,
58
+ tunnelName: null,
59
+ tunnelHostname: null,
60
+ noDownload: false,
61
+ rotate: false,
62
+ daemon: false,
63
+ printPairing: false,
64
+ };
65
+ const args = [...argv];
66
+ if (args[0] && !args[0].startsWith("-")) options.command = args.shift();
67
+ while (args.length > 0) {
68
+ const arg = args.shift();
69
+ if (arg === "--dir") options.dir = requiredValue(arg, args.shift());
70
+ else if (arg === "--ref") options.ref = requiredValue(arg, args.shift());
71
+ else if (arg === "--source")
72
+ options.source = requiredValue(arg, args.shift());
73
+ else if (arg === "--source-sha256")
74
+ options.sourceSha256 = requiredValue(arg, args.shift());
75
+ else if (arg === "--start") options.start = true;
76
+ else if (arg === "--dry-run") options.dryRun = true;
77
+ else if (arg === "--yes" || arg === "-y") options.yes = true;
78
+ else if (arg === "--help" || arg === "-h") options.help = true;
79
+ else if (arg === "--port") options.port = requiredValue(arg, args.shift());
80
+ else if (arg === "--tunnel-url")
81
+ options.tunnelUrl = requiredValue(arg, args.shift());
82
+ else if (arg === "--tunnel-name")
83
+ options.tunnelName = requiredValue(arg, args.shift());
84
+ else if (arg === "--tunnel-hostname")
85
+ options.tunnelHostname = requiredValue(arg, args.shift());
86
+ else if (arg === "--no-download") options.noDownload = true;
87
+ else if (arg === "--rotate") options.rotate = true;
88
+ else if (arg === "--daemon") options.daemon = true;
89
+ else if (arg === "--print-pairing") options.printPairing = true;
90
+ else throw new Error(`Unknown option: ${arg}`);
91
+ }
92
+ return options;
93
+ }
94
+
95
+ function requiredValue(flag, value) {
96
+ if (!value || value.startsWith("-"))
97
+ throw new Error(`${flag} requires a value.`);
98
+ return value;
99
+ }
100
+
101
+ export function helpText() {
102
+ return `AIOS Dashboard
103
+
104
+ Usage:
105
+ aios-dashboard init [--dir <workspace>] [--source <url-or-path> --source-sha256 <hash>] [--start]
106
+ aios-dashboard start [--dir <workspace>] [--port <port>]
107
+ aios-dashboard open [--dir <workspace>] [--port <port>]
108
+ aios-dashboard status [--dir <workspace>]
109
+ aios-dashboard stop [--dir <workspace>]
110
+ aios-dashboard connect [--dir <workspace>] [--port <port>]
111
+
112
+ Commands:
113
+ init Install the Dashboard into an AIOS workspace
114
+ start Start the installed production Dashboard in the background
115
+ open Open the installed Dashboard in a browser
116
+ status Show whether the local Dashboard is running
117
+ stop Stop the local Dashboard
118
+ connect Let a hosted Dashboard run Claude Code on this computer
119
+
120
+ Options for init:
121
+ --dir <workspace> AIOS workspace (default: current directory)
122
+ --ref <tag> Install a specific Git tag/branch from GitHub
123
+ --source <value> Install an MCP URL, file URL, or local release ZIP
124
+ --source-sha256 Required SHA-256 for --source (64 hexadecimal characters)
125
+ --start Build, start the production dashboard on loopback, and open it
126
+ --yes, -y Accept every prompt without asking
127
+
128
+ Options for connect:
129
+ --port <port> Local port for the runner (default: 8099)
130
+ --tunnel-url <origin> Use a tunnel you already run instead of cloudflared
131
+ --tunnel-name <name> Run a named Cloudflare tunnel instead of a quick one
132
+ --rotate Mint a new pairing code, invalidating the old one
133
+ --daemon Install a user service so it survives a reboot
134
+ Run \`aios-dashboard connect --help\` for the full list.
135
+
136
+ Shared options:
137
+ --dry-run Print the plan without changing anything
138
+ --help, -h Show this help
139
+ `;
140
+ }
141
+
142
+ export function npxLifecycleCommand(version, command, workspace) {
143
+ return npxLifecycleCommandForPlatform(version, command, workspace);
144
+ }
145
+
146
+ function shellQuote(value, platform) {
147
+ const text = String(value);
148
+ return platform === "win32"
149
+ ? `'${text.replaceAll("'", "''")}'`
150
+ : `'${text.replaceAll("'", `'"'"'`)}'`;
151
+ }
152
+
153
+ export function npxLifecycleCommandForPlatform(
154
+ version,
155
+ command,
156
+ workspace,
157
+ platform = process.platform,
158
+ ) {
159
+ const executable = platform === "win32" ? "npx.cmd" : "npx";
160
+ return `${executable} --yes --package aios-dashboard@${version} aios-dashboard ${command} --dir ${shellQuote(workspace, platform)}`;
161
+ }
162
+
163
+ async function confirm(question, fallback = true) {
164
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return fallback;
165
+ const prompt = createInterface({
166
+ input: process.stdin,
167
+ output: process.stdout,
168
+ });
169
+ try {
170
+ const answer = (await prompt.question(`${question} [Y/n] `))
171
+ .trim()
172
+ .toLowerCase();
173
+ return answer === "" || answer === "y" || answer === "yes";
174
+ } finally {
175
+ prompt.close();
176
+ }
177
+ }
178
+
179
+ async function preflight(options) {
180
+ if (!supportsNode()) {
181
+ throw new Error(`Node.js ${MINIMUM_NODE_VERSION} or newer is required.`);
182
+ }
183
+ const workspace = workspacePath(options.dir);
184
+ const target = dashboardPath(workspace);
185
+ const hasWorkspace = workspacePresent(workspace);
186
+ if (!hasWorkspace && !options.yes) {
187
+ const proceed = await confirm(
188
+ "No AIOS workspace (CLAUDE.md + context/) was found. Continue anyway?",
189
+ );
190
+ if (!proceed) throw new Error("Installation cancelled.");
191
+ }
192
+
193
+ const configuredUrl = workspaceDatabaseUrl(workspace);
194
+ const databaseUrl = configuredUrl;
195
+ let databaseReachableNow = false;
196
+ if (configuredUrl) {
197
+ const reachable = options.dryRun
198
+ ? true
199
+ : await databaseReachable(configuredUrl);
200
+ databaseReachableNow = reachable;
201
+ if (!reachable) {
202
+ console.warn(
203
+ "! Member Supabase is configured but not reachable; the dashboard will show its not-connected states.",
204
+ );
205
+ if (!options.yes) {
206
+ const proceed = await confirm(
207
+ "Keep the configured URL and continue while it is unreachable?",
208
+ );
209
+ if (!proceed) throw new Error("Installation cancelled.");
210
+ }
211
+ }
212
+ }
213
+
214
+ const missingBuildTools = missingNativeBuildTools();
215
+ if (missingBuildTools.length > 0) {
216
+ throw new Error(
217
+ `Linux native build prerequisites are missing: ${missingBuildTools.join(", ")}. Install Python 3, make, and a C/C++ toolchain (for example, the build-essential package), then retry.`,
218
+ );
219
+ }
220
+ const packageManager = choosePackageManager();
221
+ const claude = findExecutable("claude");
222
+ return {
223
+ workspace,
224
+ target,
225
+ hasWorkspace,
226
+ databaseUrl,
227
+ databaseReachableNow,
228
+ packageManager,
229
+ claude,
230
+ };
231
+ }
232
+
233
+ function printPlan(context, options, version) {
234
+ const source = options.source
235
+ ? redactSource(options.source)
236
+ : options.ref
237
+ ? `GitHub ref ${options.ref}`
238
+ : `release v${version}`;
239
+ console.log("\nAIOS Dashboard install plan");
240
+ console.log(` Source: ${source}`);
241
+ console.log(
242
+ ` Integrity: ${options.sourceSha256 ? `SHA-256 ${options.sourceSha256}` : "GitHub release transport"}`,
243
+ );
244
+ console.log(` Workspace: ${context.workspace}`);
245
+ console.log(` Destination: ${context.target}`);
246
+ console.log(
247
+ ` Mode: ${
248
+ context.databaseUrl
249
+ ? context.databaseReachableNow
250
+ ? "local + member Supabase"
251
+ : "local + configured member Supabase (currently unreachable)"
252
+ : "local, no member database"
253
+ }`,
254
+ );
255
+ console.log(` Database: ${redactDatabaseUrl(context.databaseUrl)}`);
256
+ console.log(
257
+ ` Dependencies: pnpm install --frozen-lockfile --prod=false (pnpm ${context.packageManager.version || "10.30.3"})`,
258
+ );
259
+ console.log(
260
+ ` Claude CLI: ${context.claude ? "found" : "not found (warning only)"}`,
261
+ );
262
+ if (existsSync(context.target))
263
+ console.log(
264
+ " Update: replace app files; preserve .env, data/, and learnings.md",
265
+ );
266
+ console.log("");
267
+ }
268
+
269
+ function runPackageCommand(context, cwd, args, label) {
270
+ const invocation = portableCommand(context.packageManager.command, [
271
+ ...(context.packageManager.commandArgs || []),
272
+ ...args,
273
+ ]);
274
+ const result = spawnSync(invocation.command, invocation.args, {
275
+ cwd,
276
+ stdio: "inherit",
277
+ shell: false,
278
+ env: process.env,
279
+ });
280
+ if (result.status !== 0) {
281
+ throw new Error(`${label} failed with exit code ${result.status}.`);
282
+ }
283
+ }
284
+
285
+ export async function ensureNodePtyHelperExecutable(
286
+ cwd,
287
+ { platform = process.platform, arch = process.arch } = {},
288
+ ) {
289
+ if (platform !== "darwin") return false;
290
+ const helper = path.join(
291
+ cwd,
292
+ "node_modules",
293
+ "node-pty",
294
+ "prebuilds",
295
+ `darwin-${arch}`,
296
+ "spawn-helper",
297
+ );
298
+ if (!existsSync(helper)) {
299
+ throw new Error(
300
+ `node-pty did not install its macOS ${arch} spawn-helper at the expected path.`,
301
+ );
302
+ }
303
+ await chmod(helper, 0o755);
304
+ return true;
305
+ }
306
+
307
+ async function assertSafeMemberPath(candidate, relative) {
308
+ const info = await lstat(candidate);
309
+ if (info.isSymbolicLink()) {
310
+ throw new Error(
311
+ `Refusing to preserve member-owned ${relative}: symbolic links are not allowed in Dashboard local data.`,
312
+ );
313
+ }
314
+ if (info.isDirectory()) {
315
+ for (const entry of await readdir(candidate)) {
316
+ await assertSafeMemberPath(
317
+ path.join(candidate, entry),
318
+ path.join(relative, entry),
319
+ );
320
+ }
321
+ return;
322
+ }
323
+ if (!info.isFile()) {
324
+ throw new Error(
325
+ `Refusing to preserve unsupported member-owned path: ${relative}`,
326
+ );
327
+ }
328
+ }
329
+
330
+ export async function preserveMemberData(existingTarget, stagedTarget) {
331
+ if (!existsSync(existingTarget)) return [];
332
+ const preserved = [];
333
+ for (const relative of MEMBER_OWNED_PATHS) {
334
+ const source = path.join(existingTarget, relative);
335
+ if (!existsSync(source)) continue;
336
+ await assertSafeMemberPath(source, relative);
337
+ await cp(source, path.join(stagedTarget, relative), {
338
+ recursive: true,
339
+ dereference: false,
340
+ force: true,
341
+ });
342
+ preserved.push(relative);
343
+ }
344
+ return preserved;
345
+ }
346
+
347
+ export async function removeBackupAfterCommit(
348
+ backup,
349
+ { remove = rm, warn = console.warn } = {},
350
+ ) {
351
+ try {
352
+ await remove(backup, { recursive: true, force: true });
353
+ return true;
354
+ } catch (error) {
355
+ warn(
356
+ `! The verified Dashboard is installed, but its previous-version backup could not be removed at ${backup}: ${error.message}`,
357
+ );
358
+ return false;
359
+ }
360
+ }
361
+
362
+ export function assertUpdateRuntimeSafe(status) {
363
+ if (status.identityMismatch) {
364
+ throw new Error(
365
+ `Refusing to update while Dashboard runtime state points at reused PID ${status.runtime.pid}. It will not be signalled; inspect ${status.runtime.log}, remove the stale runtime state only after confirming the old Dashboard is stopped, then retry.`,
366
+ );
367
+ }
368
+ if (status.tokenMismatch) {
369
+ throw new Error(
370
+ `Refusing to update while Dashboard process ${status.runtime.pid} is alive but runtime ownership cannot be verified. Stop that Dashboard manually, then retry; its runtime state was kept for inspection.`,
371
+ );
372
+ }
373
+ }
374
+
375
+ async function installSource(
376
+ context,
377
+ buffer,
378
+ beforeSwap = async () => undefined,
379
+ ) {
380
+ await mkdir(context.workspace, { recursive: true });
381
+ const temporary = await mkdtemp(
382
+ path.join(os.tmpdir(), "aios-dashboard-install-"),
383
+ );
384
+ const extracted = path.join(temporary, "archive");
385
+ const staged = path.join(
386
+ context.workspace,
387
+ `.dashboard-stage-${process.pid}-${Date.now()}`,
388
+ );
389
+ const backup = path.join(
390
+ context.workspace,
391
+ `.dashboard-backup-${process.pid}-${Date.now()}`,
392
+ );
393
+ let movedExisting = false;
394
+ try {
395
+ await extractZip(buffer, extracted);
396
+ const sourceRoot = await findSourceRoot(extracted);
397
+ await cp(sourceRoot, staged, { recursive: true, dereference: true });
398
+
399
+ let existingEnv = "";
400
+ if (existsSync(path.join(context.target, ".env"))) {
401
+ existingEnv = await readFile(path.join(context.target, ".env"), "utf8");
402
+ }
403
+ const nextEnv = mergeEnv(
404
+ existingEnv,
405
+ installerEnv({
406
+ workspace: context.workspace,
407
+ databaseUrl: context.databaseUrl,
408
+ }),
409
+ { replaceBlankKeys: ["AIOS_DASHBOARD_DB_URL"] },
410
+ );
411
+ await writeFile(path.join(staged, ".env"), nextEnv, { mode: 0o600 });
412
+
413
+ runPackageCommand(
414
+ context,
415
+ staged,
416
+ context.packageManager.installArgs,
417
+ `${context.packageManager.name} install`,
418
+ );
419
+ await ensureNodePtyHelperExecutable(staged);
420
+ runPackageCommand(
421
+ context,
422
+ staged,
423
+ context.packageManager.name === "pnpm" ? ["build"] : ["run", "build"],
424
+ `${context.packageManager.name} build`,
425
+ );
426
+
427
+ await beforeSwap();
428
+ const preserved = await preserveMemberData(context.target, staged);
429
+ if (preserved.length > 0) {
430
+ console.log(
431
+ `Preserved member-owned local data: ${preserved.join(", ")}.`,
432
+ );
433
+ }
434
+ if (existsSync(context.target)) {
435
+ await rename(context.target, backup);
436
+ movedExisting = true;
437
+ }
438
+ await rename(staged, context.target);
439
+ return {
440
+ movedExisting,
441
+ async commit() {
442
+ if (movedExisting) await removeBackupAfterCommit(backup);
443
+ },
444
+ async rollback() {
445
+ if (!movedExisting || !existsSync(backup)) return false;
446
+ await rm(context.target, { recursive: true, force: true });
447
+ await rename(backup, context.target);
448
+ return true;
449
+ },
450
+ };
451
+ } catch (error) {
452
+ await rm(staged, { recursive: true, force: true }).catch(() => undefined);
453
+ if (movedExisting && existsSync(backup)) {
454
+ await rm(context.target, { recursive: true, force: true }).catch(
455
+ () => undefined,
456
+ );
457
+ await rename(backup, context.target).catch(() => undefined);
458
+ }
459
+ throw error;
460
+ } finally {
461
+ await rm(temporary, { recursive: true, force: true }).catch(
462
+ () => undefined,
463
+ );
464
+ }
465
+ }
466
+
467
+ async function runLifecycleCommand(options) {
468
+ const workspace = workspacePath(options.dir);
469
+ if (!supportsNode()) {
470
+ throw new Error(`Node.js ${MINIMUM_NODE_VERSION} or newer is required.`);
471
+ }
472
+ if (options.command === "status") {
473
+ const status = await dashboardStatus(workspace);
474
+ if (!status.running) {
475
+ if (status.identityMismatch) {
476
+ console.log(
477
+ `AIOS Dashboard runtime state is stale: PID ${status.runtime.pid} belongs to another process. It will not be signalled.`,
478
+ );
479
+ return;
480
+ }
481
+ if (status.tokenMismatch) {
482
+ console.log(
483
+ `AIOS Dashboard runtime ownership could not be verified at ${status.runtime.url}. It will not be signalled.`,
484
+ );
485
+ return;
486
+ }
487
+ console.log("AIOS Dashboard is stopped.");
488
+ return;
489
+ }
490
+ console.log(
491
+ `AIOS Dashboard is ${status.healthy ? "running" : "starting or unhealthy"} at ${status.runtime.url} (PID ${status.runtime.pid}).`,
492
+ );
493
+ console.log(`Logs: ${status.runtime.log}`);
494
+ return;
495
+ }
496
+ if (options.command === "stop") {
497
+ if (options.dryRun) {
498
+ console.log(`Would stop the AIOS Dashboard for ${workspace}.`);
499
+ return;
500
+ }
501
+ const result = await stopDashboard(workspace);
502
+ console.log(
503
+ result.runtime
504
+ ? "AIOS Dashboard stopped."
505
+ : "AIOS Dashboard is already stopped.",
506
+ );
507
+ return;
508
+ }
509
+ const existing = await dashboardStatus(workspace);
510
+ const port = await resolveDashboardPort({
511
+ workspace,
512
+ explicit: options.port,
513
+ runtime: existing.runtime,
514
+ });
515
+ const url = existing.runtime?.url || `http://127.0.0.1:${port}`;
516
+ if (options.command === "open") {
517
+ if (options.dryRun) {
518
+ console.log(`Would open ${url}.`);
519
+ return;
520
+ }
521
+ openDashboard(url);
522
+ console.log(`Opened ${url}.`);
523
+ return;
524
+ }
525
+ if (options.command === "start") {
526
+ if (options.dryRun) {
527
+ console.log(`Would start the production Dashboard at ${url}.`);
528
+ return;
529
+ }
530
+ const runtime = await startDashboard({ workspace, port });
531
+ console.log(
532
+ runtime.alreadyRunning
533
+ ? `AIOS Dashboard is already running at ${runtime.url}.`
534
+ : `AIOS Dashboard started at ${runtime.url}.`,
535
+ );
536
+ console.log(`Logs: ${runtime.log}`);
537
+ }
538
+ }
539
+
540
+ export async function runInstaller(options, version) {
541
+ if (!options.command || (options.help && !options.command)) {
542
+ console.log(helpText());
543
+ return;
544
+ }
545
+ if (options.command === "connect") {
546
+ const { runConnect } = await import("./connect.mjs");
547
+ await runConnect(options);
548
+ return;
549
+ }
550
+ if (options.help) {
551
+ console.log(helpText());
552
+ return;
553
+ }
554
+ if (["start", "open", "status", "stop"].includes(options.command)) {
555
+ await runLifecycleCommand(options);
556
+ return;
557
+ }
558
+ if (options.command !== "init")
559
+ throw new Error(`Unknown command: ${options.command}`);
560
+ if (options.sourceSha256)
561
+ options.sourceSha256 = normalizeSha256(options.sourceSha256);
562
+ if (options.source && !options.sourceSha256) {
563
+ throw new Error(
564
+ "--source requires --source-sha256 so the artifact can be verified.",
565
+ );
566
+ }
567
+ if (options.sourceSha256 && !options.source) {
568
+ throw new Error("--source-sha256 can only be used with --source.");
569
+ }
570
+ if (!options.source && !options.ref) {
571
+ throw new Error(
572
+ "Dashboard releases are member-only. Ask Claude to install AIOS so the MCP can provide an entitled --source and --source-sha256. Maintainers may use --ref for development.",
573
+ );
574
+ }
575
+ const context = await preflight(options);
576
+ printPlan(context, options, version);
577
+ if (options.dryRun) {
578
+ console.log("Dry run complete; no files were changed.");
579
+ return;
580
+ }
581
+ if (!context.claude) {
582
+ console.warn(
583
+ "! Claude CLI was not found. The dashboard will work, but local chat/commands will not.",
584
+ );
585
+ }
586
+ const buffer = await downloadSource({
587
+ version,
588
+ ref: options.ref,
589
+ source: options.source,
590
+ sourceSha256: options.sourceSha256,
591
+ });
592
+ let wasRunning = false;
593
+ let previousRuntime = null;
594
+ let transaction;
595
+ try {
596
+ transaction = await installSource(context, buffer, async () => {
597
+ const status = await dashboardStatus(context.workspace);
598
+ assertUpdateRuntimeSafe(status);
599
+ if (
600
+ existsSync(context.target) &&
601
+ !status.running &&
602
+ (await unmanagedDashboardReachable(context.workspace, { status }))
603
+ ) {
604
+ throw new Error(
605
+ "An older AIOS Dashboard is running without managed runtime state. Stop its dev server or terminal first, then run the update again.",
606
+ );
607
+ }
608
+ wasRunning = status.running;
609
+ previousRuntime = status.runtime;
610
+ if (wasRunning) {
611
+ console.log("Stopping the current Dashboard for the atomic update…");
612
+ await stopDashboard(context.workspace);
613
+ }
614
+ });
615
+ } catch (error) {
616
+ if (wasRunning) {
617
+ await startDashboard({
618
+ workspace: context.workspace,
619
+ packageManager: context.packageManager,
620
+ port: previousRuntime?.port,
621
+ }).catch((restartError) => {
622
+ console.warn(
623
+ `! Previous Dashboard was restored but could not restart: ${restartError.message}`,
624
+ );
625
+ });
626
+ }
627
+ throw error;
628
+ }
629
+
630
+ try {
631
+ let runtime = null;
632
+ if (options.start || wasRunning || transaction.movedExisting) {
633
+ runtime = await startDashboard({
634
+ workspace: context.workspace,
635
+ packageManager: context.packageManager,
636
+ port: options.port || previousRuntime?.port,
637
+ });
638
+ }
639
+ if (runtime && transaction.movedExisting && !options.start && !wasRunning) {
640
+ console.log(
641
+ "The replacement Dashboard passed production health; stopping it because it was not running before the update…",
642
+ );
643
+ await stopDashboard(context.workspace);
644
+ runtime = null;
645
+ }
646
+ await transaction.commit();
647
+ console.log("AIOS Dashboard installed successfully.");
648
+ if (runtime) {
649
+ console.log(`AIOS Dashboard started at ${runtime.url}.`);
650
+ console.log(`Logs: ${runtime.log}`);
651
+ if (options.start) openDashboard(runtime.url);
652
+ } else {
653
+ console.log(
654
+ `\nStart it later with:\n ${npxLifecycleCommand(version, "start", context.workspace)}`,
655
+ );
656
+ }
657
+ console.log(
658
+ "\nAUTH_DISABLED=true is for this local, single-user install only. Never use it on a public hosted copy.",
659
+ );
660
+ } catch (error) {
661
+ if (transaction.movedExisting) {
662
+ try {
663
+ await stopDashboard(context.workspace);
664
+ const stoppedStatus = await dashboardStatus(context.workspace);
665
+ if (
666
+ stoppedStatus.running ||
667
+ stoppedStatus.identityMismatch ||
668
+ stoppedStatus.tokenMismatch ||
669
+ (await unmanagedDashboardReachable(context.workspace, {
670
+ status: stoppedStatus,
671
+ port: options.port || previousRuntime?.port,
672
+ }))
673
+ ) {
674
+ throw new Error(
675
+ "the replacement Dashboard could not be proven stopped",
676
+ );
677
+ }
678
+ } catch (stopError) {
679
+ throw new Error(
680
+ `${error.message} Rollback was not attempted because ${stopError.message}. The replacement and previous-version backup were kept for safe manual recovery.`,
681
+ );
682
+ }
683
+ const restored = await transaction.rollback();
684
+ if (restored && wasRunning) {
685
+ await startDashboard({
686
+ workspace: context.workspace,
687
+ packageManager: context.packageManager,
688
+ port: previousRuntime?.port,
689
+ }).catch((restartError) => {
690
+ console.warn(
691
+ `! Previous Dashboard was restored but could not restart: ${restartError.message}`,
692
+ );
693
+ });
694
+ }
695
+ }
696
+ throw error;
697
+ }
698
+ }