run-spaceapp 1.0.2 → 1.0.35

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/src/cli.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { createReadStream, createWriteStream } from "node:fs";
3
+ import { createWriteStream } from "node:fs";
4
4
  import { createInterface } from "node:readline";
5
+ import { once } from "node:events";
6
+ import { PromptController } from "./prompt-controller.mjs";
5
7
  import {
6
8
  copyFile,
7
9
  mkdir,
@@ -24,6 +26,7 @@ import {
24
26
  initializeInstallation,
25
27
  inspectSystemResources,
26
28
  installResourceChecks,
29
+ profileRuntimeSettings,
27
30
  loadConfig,
28
31
  planConfigRepairs,
29
32
  prepareInstallation,
@@ -40,6 +43,7 @@ import {
40
43
  writeRuntimeFiles,
41
44
  writeSetupToken
42
45
  } from "./index.mjs";
46
+ import { inspectStoragePlan, storageChecks } from "./storage.mjs";
43
47
  import {
44
48
  ensureDockerAvailable,
45
49
  prepareDockerCliPath,
@@ -59,6 +63,7 @@ import {
59
63
  const APPLICATION_READY_WAIT_MINUTES = 10;
60
64
  const APPLICATION_READY_WAIT_MS = APPLICATION_READY_WAIT_MINUTES * 60 * 1_000;
61
65
  const APPLICATION_READY_POLL_MS = 2_000;
66
+ const APPLICATION_READY_ATTEMPT_TIMEOUT_MS = 5_000;
62
67
  const APPLICATION_READY_MAX_ATTEMPTS = APPLICATION_READY_WAIT_MS / APPLICATION_READY_POLL_MS;
63
68
  const APPLICATION_READY_PROGRESS_ATTEMPTS = 30_000 / APPLICATION_READY_POLL_MS;
64
69
  const APPLICATION_READY_LOG_INTERVAL_SECONDS = 120;
@@ -114,37 +119,44 @@ export async function withHeadlessDockerConfig(platform, spec, run) {
114
119
  }
115
120
  }
116
121
 
122
+ function approvedAutomation(stdin) {
123
+ const controller = stdin?._spaceappPromptController;
124
+ return controller?.nonInteractive === true && controller.answers.confirm === true;
125
+ }
126
+
117
127
  function interactiveAvailable(stdin) {
118
128
  return Boolean(stdin?.isTTY && typeof stdin?.setRawMode === "function");
119
129
  }
120
130
 
121
- async function promptYesNo(stdin, stdout, question, { defaultYes = false } = {}) {
131
+ async function promptYesNo(stdin, stdout, question, { defaultYes = false, answerKey = null } = {}) {
122
132
  for (;;) {
123
- const answer = (await readSecret(stdin, stdout, `${question} [${defaultYes ? "Y/n" : "y/N"}] `, { mask: false })).trim().toLowerCase();
133
+ const answer = (await readSecret(stdin, stdout, `${question} [${defaultYes ? "Y/n" : "y/N"}] `, { mask: false, answerKey, defaultValue: defaultYes ? "y" : "n" })).trim().toLowerCase();
124
134
  if (answer === "") {
125
135
  return defaultYes;
126
136
  }
127
- if (answer === "y" || answer === "yes") {
137
+ if (answer === "y" || answer === "yes" || answer === "true") {
128
138
  return true;
129
139
  }
130
- if (answer === "n" || answer === "no") {
140
+ if (answer === "n" || answer === "no" || answer === "false") {
131
141
  return false;
132
142
  }
133
143
  stdout.write("Please answer y or n.\n");
134
144
  }
135
145
  }
136
146
 
137
- async function promptChoice(stdin, stdout, question, options, { defaultIndex = 0 } = {}) {
147
+ async function promptChoice(stdin, stdout, question, options, { defaultIndex = 0, answerKey = null } = {}) {
138
148
  stdout.write(`${question}\n`);
139
149
  options.forEach((option, index) => {
140
150
  stdout.write(` [${index + 1}] ${option.label}\n`);
141
151
  });
142
152
  for (;;) {
143
- const answer = (await readSecret(stdin, stdout, `Select 1-${options.length} [${defaultIndex + 1}]: `, { mask: false })).trim();
153
+ const answer = (await readSecret(stdin, stdout, `Select 1-${options.length} [${defaultIndex + 1}]: `, { mask: false, answerKey, defaultValue: String(defaultIndex + 1) })).trim();
144
154
  if (answer === "") {
145
155
  return options[defaultIndex].value;
146
156
  }
147
- const index = Number.parseInt(answer, 10);
157
+ const byValue = options.find((option) => option.value === answer);
158
+ if (byValue) return byValue.value;
159
+ const index = /^\d+$/.test(answer) ? Number(answer) : NaN;
148
160
  if (Number.isInteger(index) && index >= 1 && index <= options.length) {
149
161
  return options[index - 1].value;
150
162
  }
@@ -157,7 +169,7 @@ async function finalConfirmation(stdin, stdout, lines) {
157
169
  for (const line of lines) {
158
170
  stdout.write(` - ${line}\n`);
159
171
  }
160
- const approved = await promptYesNo(stdin, stdout, "Apply this plan?", { defaultYes: false });
172
+ const approved = await promptYesNo(stdin, stdout, "Apply this plan?", { defaultYes: false, answerKey: "confirm" });
161
173
  if (!approved) {
162
174
  stdout.write("Cancelled. No changes were made.\n");
163
175
  }
@@ -265,9 +277,73 @@ export async function run(argv, {
265
277
  runtimeVersion = RUNTIME_VERSION,
266
278
  hostRootRuntimeCompatible = HOST_ROOT_RUNTIME_COMPATIBLE
267
279
  } = {}) {
268
- const [command = "help", ...args] = argv;
280
+ const globalFlags = {
281
+ localImages: false,
282
+ json: false,
283
+ plan: false,
284
+ nonInteractive: false,
285
+ logFile: null,
286
+ answersFile: null
287
+ };
288
+ const filteredArgv = [];
289
+ for (let i = 0; i < argv.length; i++) {
290
+ const arg = argv[i];
291
+ if (arg === "--local-images") {
292
+ globalFlags.localImages = true;
293
+ } else if (arg === "--json") {
294
+ globalFlags.json = true;
295
+ } else if (arg === "--plan") {
296
+ globalFlags.plan = true;
297
+ } else if (arg === "--non-interactive") {
298
+ globalFlags.nonInteractive = true;
299
+ } else if (arg === "--log-file" && i + 1 < argv.length) {
300
+ globalFlags.logFile = argv[++i];
301
+ } else if (arg.startsWith("--log-file=")) {
302
+ globalFlags.logFile = arg.slice("--log-file=".length);
303
+ } else if (arg === "--answers" && i + 1 < argv.length) {
304
+ globalFlags.answersFile = argv[++i];
305
+ } else if (arg.startsWith("--answers=")) {
306
+ globalFlags.answersFile = arg.slice("--answers=".length);
307
+ } else {
308
+ filteredArgv.push(arg);
309
+ }
310
+ }
311
+
312
+ const promptController = new PromptController({
313
+ stdin,
314
+ stdout,
315
+ stderr,
316
+ logFile: globalFlags.logFile,
317
+ nonInteractive: globalFlags.nonInteractive,
318
+ answersFile: globalFlags.answersFile,
319
+ planMode: globalFlags.plan,
320
+ jsonMode: globalFlags.json
321
+ });
322
+ if (stdin) {
323
+ stdin._spaceappPromptController = promptController;
324
+ }
325
+
326
+ const [command = "help", ...args] = filteredArgv;
269
327
  const root = resolveSpaceAppHome({ env, platform });
270
328
 
329
+ if (["help", "--help", "-h"].includes(command) || args.includes("--help") || args.includes("-h")) {
330
+ stdout.write(helpText());
331
+ return 0;
332
+ }
333
+ if (globalFlags.localImages) {
334
+ const originalExecute = execute;
335
+ execute = async (spec, io) => {
336
+ if (spec.command === "docker" && spec.args?.includes("compose")) {
337
+ if (spec.args.at(-1) === "pull") {
338
+ stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n");
339
+ return 0;
340
+ }
341
+ const index = spec.args.indexOf("up");
342
+ if (index !== -1) spec = {...spec, args: [...spec.args.slice(0,index+1), "--pull", "never", ...spec.args.slice(index+1)]};
343
+ }
344
+ return originalExecute(spec, io);
345
+ };
346
+ }
271
347
  if (command !== "install") {
272
348
  await prepareDockerPath({ platform, env });
273
349
  }
@@ -279,6 +355,10 @@ export async function run(argv, {
279
355
  stdout.write(`${launcherVersion}\n`);
280
356
  return 0;
281
357
  }
358
+ if (globalFlags.plan) {
359
+ stdout.write(`[PLAN] Preview execution for "${command}" at ${root} (no changes applied).\n`);
360
+ return 0;
361
+ }
282
362
  if (command === "install") {
283
363
  return installCommand(args, {
284
364
  root,
@@ -321,6 +401,14 @@ export async function run(argv, {
321
401
  return 0;
322
402
  }
323
403
 
404
+ if (command === "doctor" && !args.includes("--fix")) {
405
+ assertNoArgs(args, "doctor");
406
+ return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources });
407
+ }
408
+ if (command === "repair" || command === "doctor" && args.includes("--fix")) {
409
+ if (args.some((arg) => !["--dry-run", "--fix"].includes(arg))) throw new Error("Usage: repair [--dry-run] or doctor --fix");
410
+ return repairRuntime({ root, config: null, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, env, arch, inspectResources, dryRun: args.includes("--dry-run") });
411
+ }
324
412
  const config = await loadConfig(root);
325
413
  if (commandNeedsRuntimeFiles(command, args)) {
326
414
  await writeRuntimeFiles(root, config);
@@ -349,6 +437,26 @@ export async function run(argv, {
349
437
  assertNoArgs(args, "doctor");
350
438
  return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources });
351
439
  }
440
+ if (command === "support-bundle") {
441
+ let outPath = null;
442
+ const outIdx = args.indexOf("--out");
443
+ if (outIdx !== -1 && args[outIdx + 1]) {
444
+ outPath = args[outIdx + 1];
445
+ }
446
+ return supportBundleCommand({ root, config, platform, arch, stdout, stderr, execute: runtimeExecute, request, outPath });
447
+ }
448
+ if (command === "repair") {
449
+ const dryRun = args.includes("--dry-run");
450
+ return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, dryRun });
451
+ }
452
+ if (command === "reinstall") {
453
+ const keepData = !args.includes("--purge-data");
454
+ return reinstallCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, request, sleep, keepData });
455
+ }
456
+ if (command === "factory-reset") {
457
+ assertNoArgs(args, "factory-reset");
458
+ return factoryResetCommand({ root, config, platform, stdin, stdout, stderr, execute: runtimeExecute, runtimeVersion, persistSetupToken });
459
+ }
352
460
  if (command === "workspace") {
353
461
  return workspaceCommand(args, { root, config, stdout });
354
462
  }
@@ -370,7 +478,10 @@ export async function run(argv, {
370
478
  stdin,
371
479
  stdout,
372
480
  stderr,
373
- execute: runtimeExecute
481
+ execute: runtimeExecute,
482
+ request,
483
+ sleep,
484
+ inspectResources, ensureDocker, env, arch
374
485
  });
375
486
  }
376
487
  if (command === "rollback") {
@@ -518,7 +629,7 @@ async function installCommand(args, {
518
629
  stdout,
519
630
  stderr,
520
631
  execute,
521
- inspectResources
632
+ inspectResources, request, sleep, ensureDocker, env, arch
522
633
  });
523
634
  if (wizard.exit !== undefined) {
524
635
  return wizard.exit;
@@ -545,7 +656,8 @@ async function installCommand(args, {
545
656
  }
546
657
  await prepareDockerPath({ platform, env });
547
658
  const resources = await inspectResources(root);
548
- const profile = resolveInstallProfile(requestedProfile, resources.totalMemoryBytes);
659
+ let profile = requestedProfile === "auto" && existingConfig
660
+ ? existingConfig.profile : resolveInstallProfile(requestedProfile, resources);
549
661
  if (accessMode === "host-root") {
550
662
  stderr.write(
551
663
  "WARNING: host-root access lets SpaceApp CLI sessions read and modify the entire Linux host through /host, including credentials and system files.\n"
@@ -578,7 +690,8 @@ async function installCommand(args, {
578
690
  `Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n`
579
691
  );
580
692
  stdout.write(`SpaceApp installation root: ${root}\n`);
581
- if (installResourceChecks(resources).some((check) => !check.ok)) {
693
+ const operation = existingConfig ? (existingConfig.version === runtimeVersion ? "repair" : "upgrade") : "fresh";
694
+ if (installResourceChecks(resources, profile, { operation }).some((check) => !check.ok)) {
582
695
  const doctorCode = await doctor({
583
696
  root,
584
697
  platform,
@@ -587,7 +700,8 @@ async function installCommand(args, {
587
700
  execute,
588
701
  stdin,
589
702
  inspectResources,
590
- resources
703
+ resources,
704
+ profile, operation
591
705
  });
592
706
  stderr.write(
593
707
  `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n`
@@ -603,7 +717,7 @@ async function installCommand(args, {
603
717
  stdout,
604
718
  stderr,
605
719
  execute,
606
- installArgs: { root, requestedProfile, requestedAccessMode, noOpen }
720
+ installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true }
607
721
  });
608
722
  if (prerequisiteResult.reexecuted) {
609
723
  return prerequisiteResult.code;
@@ -615,6 +729,16 @@ async function installCommand(args, {
615
729
  return prerequisiteResult.code;
616
730
  }
617
731
 
732
+ let storagePlan = await inspectStoragePlan({ root, config: result.config, existingConfig, resources, execute, operation });
733
+ if (requestedProfile === "auto" && !existingConfig) {
734
+ const engineProfile = resolveInstallProfile("auto", storagePlan.resources);
735
+ if (engineProfile !== profile) {
736
+ profile = engineProfile;
737
+ result.config = { ...result.config, profile };
738
+ stdout.write(`Docker resources select profile: ${profile}.\n`);
739
+ storagePlan = await inspectStoragePlan({ root, config: result.config, resources: storagePlan.resources, execute, operation });
740
+ }
741
+ }
618
742
  const doctorCode = await doctor({
619
743
  root,
620
744
  platform,
@@ -624,7 +748,8 @@ async function installCommand(args, {
624
748
  stdin,
625
749
  inspectResources,
626
750
  resources,
627
- dockerReady: true
751
+ profile,
752
+ dockerReady: true, operation, storagePlan
628
753
  });
629
754
  if (doctorCode !== 0) {
630
755
  stderr.write(
@@ -634,6 +759,7 @@ async function installCommand(args, {
634
759
  }
635
760
  const stagedStateRoot = await mkdtemp(join(tmpdir(), "run-spaceapp-install-"));
636
761
  let runtimeMutationAttempted = false;
762
+ let upgradeCheckpoint = null;
637
763
  const failAfterRuntimeMutation = async (code) => {
638
764
  if (runtimeMutationAttempted) {
639
765
  await reportInstallDiagnostics({
@@ -646,7 +772,10 @@ async function installCommand(args, {
646
772
  stderr,
647
773
  execute
648
774
  });
649
- await restoreRuntimeAfterFailedInstall({
775
+ if (upgradeCheckpoint) {
776
+ await retainFailedUpgrade(root, upgradeCheckpoint,
777
+ {...result.config,accessMode:existingConfig.accessMode}, {stdin,stdout,stderr,execute});
778
+ } else await restoreRuntimeAfterFailedInstall({
650
779
  root,
651
780
  stagedStateRoot,
652
781
  existingConfig,
@@ -682,7 +811,18 @@ async function installCommand(args, {
682
811
  )
683
812
  );
684
813
  if (pullCode !== 0) return pullCode;
814
+ if (await verifyStorage({ root, config: result.config, existingConfig, operation,
815
+ inspectResources, execute, stdout, stderr }) !== 0) return 1;
816
+ if(existingConfig && existingConfig.version !== result.config.version) {
817
+ upgradeCheckpoint = await createQuiescedCheckpoint(root, existingConfig, {stdin,stdout,stderr,execute,platform});
818
+ }
685
819
  runtimeMutationAttempted = true;
820
+ if (existingConfig && !profileRuntimeSettings(existingConfig.profile).temporalEnabled &&
821
+ profileRuntimeSettings(profile).temporalEnabled) {
822
+ const temporalCode = await executeWithDockerDiagnostics(execute,
823
+ stagedComposeCommand("enableTemporal"), { stdin, stdout, stderr }, { platform, stderr });
824
+ if (temporalCode !== 0) return await failAfterRuntimeMutation(temporalCode);
825
+ }
686
826
  const upCode = await executeWithDockerDiagnostics(
687
827
  execute,
688
828
  stagedComposeCommand("up"),
@@ -699,6 +839,7 @@ async function installCommand(args, {
699
839
  url,
700
840
  request,
701
841
  sleep,
842
+ attemptTimeoutMs: Number(env.SPACEAPP_READY_ATTEMPT_TIMEOUT_MS) || APPLICATION_READY_ATTEMPT_TIMEOUT_MS,
702
843
  onProgress: async ({ elapsedSeconds }) => {
703
844
  stdout.write(
704
845
  `Still waiting for SpaceApp services (${elapsedSeconds} seconds elapsed; ` +
@@ -783,23 +924,9 @@ async function installCommand(args, {
783
924
  }
784
925
  }
785
926
 
786
- if (profile === "light") {
787
- const removeBrowserCode = await executeWithDockerDiagnostics(
788
- execute,
789
- composeCommand("removeBrowser", root, {
790
- profile: "standard",
791
- stateRoot: stagedStateRoot
792
- }),
793
- { stdin, stdout, stderr },
794
- { platform, stderr }
795
- );
796
- if (removeBrowserCode !== 0) {
797
- stderr.write(
798
- "SpaceApp is ready, but the managed browser container could not be removed for the light profile.\n"
799
- );
800
- return await failAfterRuntimeMutation(removeBrowserCode);
801
- }
802
- }
927
+ const cleanupCode = await removeInactiveProfileServices({root, config: result.config,
928
+ stateRoot: stagedStateRoot, execute, stdin, stdout, stderr});
929
+ if (cleanupCode !== 0) return await failAfterRuntimeMutation(cleanupCode);
803
930
 
804
931
  if (setupToken) {
805
932
  try {
@@ -811,9 +938,12 @@ async function installCommand(args, {
811
938
  );
812
939
  return await failAfterRuntimeMutation(1);
813
940
  }
941
+
942
+
814
943
  }
815
944
 
816
945
  await commitInstallation(root, result.config);
946
+ if(upgradeCheckpoint) await markCheckpointVerified(upgradeCheckpoint);
817
947
  runtimeMutationAttempted = false;
818
948
  try {
819
949
  await reportFirstInstallPing({
@@ -840,7 +970,8 @@ async function installCommand(args, {
840
970
  stdout.write(`If it expires, run: ${UNIVERSAL_COMMAND} owner rotate-setup-token\n`);
841
971
  }
842
972
  stdout.write(
843
- `Next: add CLI credentials with "${UNIVERSAL_COMMAND} credentials set <provider>".\n`
973
+ "Next: open the Getting Started room and use OpenCode. A working free model is selected on first launch.\n" +
974
+ `Optional providers: "${UNIVERSAL_COMMAND} credentials set <provider>".\n`
844
975
  );
845
976
  if (noOpen) return 0;
846
977
  const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
@@ -856,6 +987,22 @@ async function installCommand(args, {
856
987
  }
857
988
  }
858
989
 
990
+ async function removeInactiveProfileServices({root, config, stateRoot = root, execute, stdin, stdout, stderr}) {
991
+ const settings = profileRuntimeSettings(config.profile);
992
+ for (const [enabled, action, profile] of [
993
+ [settings.browserEnabled, "removeBrowser", "standard"],
994
+ [settings.temporalEnabled, "removeTemporal", "medium"]
995
+ ]) {
996
+ if (enabled) continue;
997
+ const code = await execute(composeCommand(action, root, {profile, stateRoot}), {stdin, stdout, stderr});
998
+ if (code !== 0) {
999
+ stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n");
1000
+ return code;
1001
+ }
1002
+ }
1003
+ return 0;
1004
+ }
1005
+
859
1006
  async function planInteractiveSetup({
860
1007
  args,
861
1008
  root,
@@ -865,7 +1012,8 @@ async function planInteractiveSetup({
865
1012
  stdout,
866
1013
  stderr,
867
1014
  execute,
868
- inspectResources
1015
+ inspectResources,
1016
+ request, sleep, ensureDocker, env, arch
869
1017
  }) {
870
1018
  const parsed = parseInstallArgs(args);
871
1019
  const rawExisting = await readRawConfig(root);
@@ -874,7 +1022,7 @@ async function planInteractiveSetup({
874
1022
  const interactive = interactiveAvailable(stdin);
875
1023
  const continuation = await readRunOnceContinuation(root, { platform, runtimeVersion });
876
1024
 
877
- if (!interactive && !continuation) {
1025
+ if (!interactive && !continuation && !approvedAutomation(stdin)) {
878
1026
  stderr.write(
879
1027
  "SpaceApp setup changes require an interactive terminal (TTY). " +
880
1028
  "Re-run from a terminal, or continue an approved Windows RunOnce plan.\n"
@@ -884,7 +1032,8 @@ async function planInteractiveSetup({
884
1032
 
885
1033
  const resources = await inspectResources(root);
886
1034
  const resolveProfileChoice = (choice) => {
887
- const resolved = resolveInstallProfile(choice, resources.totalMemoryBytes);
1035
+ const resolved = choice === "auto" && rawExisting?.profile
1036
+ ? rawExisting.profile : resolveInstallProfile(choice, resources);
888
1037
  return { resolved, display: choice === "auto" ? `auto (${resolved})` : resolved };
889
1038
  };
890
1039
 
@@ -904,16 +1053,19 @@ async function planInteractiveSetup({
904
1053
 
905
1054
  if (path === "fresh") {
906
1055
  const profileChoice = await promptChoice(stdin, stdout, "Which installation profile?", [
907
- { label: `auto (light profile on this system; ${formatGibibytes(resources.totalMemoryBytes)} GiB detected)`, value: "auto" },
908
- { label: "light (smaller footprint)", value: "light" },
909
- { label: "standard (full features, incl. managed browser)", value: "standard" }
910
- ], { defaultIndex: ["auto", "light", "standard"].indexOf(parsed.requestedProfile) });
1056
+ { label: `auto (${resolveProfileChoice("auto").resolved}; ${resources.cpuCount} CPUs, ${formatGibibytes(resources.totalMemoryBytes)} GiB RAM, ${formatGibibytes(resources.freeDiskBytes)} GiB free)`, value: "auto" },
1057
+ { label: "small (SpaceApp + OpenCode; no background workflows or managed browser)", value: "small" },
1058
+ { label: "medium (background workflows and integration workers)", value: "medium" },
1059
+ { label: "large (workflows + managed browser; larger resource limits)", value: "large" },
1060
+ { label: "light (legacy compatibility)", value: "light" },
1061
+ { label: "standard (legacy compatibility)", value: "standard" }
1062
+ ], { defaultIndex: Math.max(0, ["auto", "small", "medium", "large", "light", "standard"].indexOf(parsed.requestedProfile)), answerKey: "profile" });
911
1063
  const accessChoice = await promptChoice(stdin, stdout, "Which access mode?", [
912
1064
  { label: "isolated (recommended; no host access)", value: "isolated" },
913
1065
  { label: "host-root (Linux only; CLI sessions can read and modify the whole host)", value: "host-root" }
914
- ], { defaultIndex: parsed.requestedAccessMode === "host-root" ? 1 : 0 });
915
- const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled });
916
- const telemetryChoice = await promptYesNo(stdin, stdout, "Enable anonymous usage telemetry?", { defaultYes: false });
1066
+ ], { defaultIndex: parsed.requestedAccessMode === "host-root" ? 1 : 0, answerKey: "access" });
1067
+ const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled, answerKey: "companions" });
1068
+ const telemetryChoice = await promptYesNo(stdin, stdout, "Enable anonymous usage telemetry?", { defaultYes: false, answerKey: "telemetry" });
917
1069
  let dockerChoice = true;
918
1070
  if (platform === "win32") {
919
1071
  const dockerProbe = await detectDockerAvailable({ execute });
@@ -921,7 +1073,7 @@ async function planInteractiveSetup({
921
1073
  dockerChoice = await promptYesNo(stdin, stdout, "Docker Engine was not detected. Include automatic Docker installation?", { defaultYes: true });
922
1074
  }
923
1075
  }
924
- const openBrowserChoice = await promptYesNo(stdin, stdout, "Open the web application when installation completes?", { defaultYes: !parsed.noOpen });
1076
+ const openBrowserChoice = await promptYesNo(stdin, stdout, "Open the web application when installation completes?", { defaultYes: !parsed.noOpen, answerKey: "open" });
925
1077
 
926
1078
  const { resolved } = resolveProfileChoice(profileChoice);
927
1079
  const approved = await finalConfirmation(stdin, stdout, [
@@ -961,7 +1113,7 @@ async function planInteractiveSetup({
961
1113
  { label: "Run doctor diagnostics (read-only)", value: "doctor" },
962
1114
  { label: "Repair the runtime (recreate containers from the current configuration)", value: "repair" },
963
1115
  { label: "Cancel", value: "cancel" }
964
- ]);
1116
+ ], {defaultIndex:1, answerKey:"action"});
965
1117
  if (choice === "cancel") {
966
1118
  stdout.write("Cancelled. No changes were made.\n");
967
1119
  return { exit: 0 };
@@ -971,7 +1123,9 @@ async function planInteractiveSetup({
971
1123
  }
972
1124
  await applyApprovedConfigRepairs(root, rawExisting);
973
1125
  const config = await loadExistingInstallation(root);
974
- return { exit: await repairRuntime({ root, config, platform, stdin, stdout, stderr, execute }) };
1126
+ const code = await repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, ensureDocker, env, arch, inspectResources });
1127
+ if(code === 0 && !parsed.noOpen) await openBrowser(`http://${config.bindHost}:${config.port}`, platform, execute, {stdin,stdout,stderr});
1128
+ return {exit:code};
975
1129
  }
976
1130
 
977
1131
  if (path === "downgrade") {
@@ -1006,7 +1160,7 @@ async function planInteractiveSetup({
1006
1160
  ...repairLines,
1007
1161
  path === "preserve-recreate"
1008
1162
  ? "Runtime: preserve/recreate (containers are recreated without touching data volumes)."
1009
- : "Runtime: standard staged upgrade; previous runtime is restored automatically on failure.",
1163
+ : "Runtime: staged upgrade with a verified checkpoint; a failure after cutover preserves current data for recovery with doctor --fix.",
1010
1164
  "Preserved: data, workspaces, credentials, secrets, and persistent Docker volumes."
1011
1165
  ]);
1012
1166
  if (!approved) {
@@ -1083,7 +1237,26 @@ async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout,
1083
1237
  }
1084
1238
  }
1085
1239
 
1086
- async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute }) {
1240
+ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, dryRun = false, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch, inspectResources = inspectSystemResources }) {
1241
+ const raw = await readRawConfig(root);
1242
+ const plan = planConfigRepairs(raw);
1243
+ if (dryRun) {
1244
+ stdout.write(`Config repairs planned: ${JSON.stringify(plan)}\n`);
1245
+ return 0;
1246
+ }
1247
+ if (!raw) throw new Error(`No installation found. Run "${UNIVERSAL_COMMAND} install" first.`);
1248
+ const repaired = applyConfigRepairs(raw);
1249
+ if (!await requireChangeApproval(stdin, stdout, stderr, [
1250
+ "Repair configuration and recreate the current runtime without deleting data.",
1251
+ ...plan.actions.map((action) => action.detail)
1252
+ ])) return 1;
1253
+ const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait,
1254
+ installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}});
1255
+ if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code;
1256
+ config = repaired;
1257
+ if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1;
1258
+ await saveConfig(root, config);
1259
+ await writeRuntimeFiles(root, config);
1087
1260
  const pullCode = await withHeadlessDockerConfig(
1088
1261
  platform,
1089
1262
  composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
@@ -1093,6 +1266,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex
1093
1266
  writeWindowsCredentialHint(platform, stderr);
1094
1267
  return pullCode;
1095
1268
  }
1269
+ if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1;
1096
1270
  const upCode = await execute(
1097
1271
  composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1098
1272
  { stdin, stdout, stderr }
@@ -1100,6 +1274,23 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex
1100
1274
  if (upCode !== 0) {
1101
1275
  return upCode;
1102
1276
  }
1277
+ if (typeof request === "function") {
1278
+ const url = `http://${config.bindHost}:${config.port}`;
1279
+ const ready = await waitForReadiness({
1280
+ url,
1281
+ request,
1282
+ sleep: sleep || wait,
1283
+ onProgress: async ({ elapsedSeconds }) => {
1284
+ stdout.write(`Waiting for SpaceApp readiness after repair (${elapsedSeconds}s)...\n`);
1285
+ }
1286
+ });
1287
+ if (!ready) {
1288
+ stderr.write("SpaceApp runtime repair completed, but readiness check timed out.\n");
1289
+ return 1;
1290
+ }
1291
+ }
1292
+ const cleanupCode = await removeInactiveProfileServices({root, config, execute, stdin, stdout, stderr});
1293
+ if (cleanupCode !== 0) return cleanupCode;
1103
1294
  stdout.write(`SpaceApp ${config.version} runtime repaired.\n`);
1104
1295
  return 0;
1105
1296
  }
@@ -1113,7 +1304,10 @@ async function performUpdate({
1113
1304
  stdout,
1114
1305
  stderr,
1115
1306
  execute,
1116
- preserveRecreate
1307
+ preserveRecreate,
1308
+ request,
1309
+ sleep,
1310
+ inspectResources = inspectSystemResources
1117
1311
  }) {
1118
1312
  const updated = targetVersion === config.version
1119
1313
  ? config
@@ -1122,27 +1316,22 @@ async function performUpdate({
1122
1316
  version: targetVersion,
1123
1317
  previousVersion: config.version
1124
1318
  };
1125
- const checkpoint = await createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform });
1319
+ const stagedRoot = await mkdtemp(join(tmpdir(), "spaceapp-update-"));
1320
+ let checkpoint = null;
1126
1321
  try {
1127
- await writeRuntimeFiles(root, updated);
1128
- if (preserveRecreate) {
1129
- const downCode = await execute(
1130
- composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1131
- { stdin, stdout, stderr }
1132
- );
1133
- if (downCode !== 0) {
1134
- throw new Error(`Preserve/recreate stop failed with Docker exit ${downCode}.`);
1135
- }
1136
- }
1322
+ await commitInstallation(stagedRoot, updated);
1323
+ const stagedCommand = (action) => composeCommand(action, root, {...updated, stateRoot: stagedRoot});
1137
1324
  const pullCode = await withHeadlessDockerConfig(
1138
- platform,
1139
- composeCommand("pull", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }),
1325
+ platform, stagedCommand("pull"),
1140
1326
  (pullSpec) => execute(pullSpec, { stdin, stdout, stderr })
1141
1327
  );
1142
1328
  if (pullCode !== 0) {
1143
1329
  writeWindowsCredentialHint(platform, stderr);
1144
1330
  throw new Error(`Image pull failed with Docker exit ${pullCode}.`);
1145
1331
  }
1332
+ if (await verifyStorage({ root, config: updated, existingConfig: config, operation: "upgrade", inspectResources, execute, stdout, stderr }) !== 0) return 1;
1333
+ checkpoint = await createQuiescedCheckpoint(root, config, {stdin, stdout, stderr, execute, platform});
1334
+ await commitInstallation(root, updated, { persistConfig: false });
1146
1335
  const upCode = await execute(
1147
1336
  composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }),
1148
1337
  { stdin, stdout, stderr }
@@ -1150,27 +1339,38 @@ async function performUpdate({
1150
1339
  if (upCode !== 0) {
1151
1340
  throw new Error(`Runtime start failed with Docker exit ${upCode}.`);
1152
1341
  }
1342
+ if (typeof request === "function") {
1343
+ const url = `http://${updated.bindHost}:${updated.port}`;
1344
+ const ready = await waitForReadiness({
1345
+ url,
1346
+ request,
1347
+ sleep: sleep || wait,
1348
+ onProgress: async ({ elapsedSeconds }) => {
1349
+ stdout.write(`Waiting for SpaceApp readiness after update (${elapsedSeconds}s)...\n`);
1350
+ }
1351
+ });
1352
+ if (!ready) {
1353
+ throw new Error("Updated runtime started, but readiness check timed out.");
1354
+ }
1355
+ }
1153
1356
  await saveConfig(root, updated);
1154
1357
  await markCheckpointVerified(checkpoint);
1155
1358
  stdout.write(`Updated to SpaceApp ${targetVersion}.\n`);
1156
1359
  return 0;
1157
1360
  } catch (error) {
1158
1361
  stderr.write(`SpaceApp update failed: ${error?.message || String(error)}\n`);
1159
- const restored = await restoreCheckpoint(root, checkpoint, { stdin, stdout, stderr, execute, config });
1160
- if (!restored) {
1161
- stderr.write(
1162
- `Automatic restore failed. The checkpoint remains available at ${checkpoint.path} for manual recovery.\n`
1163
- );
1164
- }
1362
+ if(checkpoint) await retainFailedUpgrade(root, checkpoint, updated, {stdin,stdout,stderr,execute});
1165
1363
  return 1;
1364
+ } finally {
1365
+ await rm(stagedRoot, {recursive: true, force: true});
1166
1366
  }
1167
1367
  }
1168
1368
 
1169
- const CHECKPOINT_ID_PATTERN = /^spaceapp-checkpoint-\d{8}T\d{6}Z$/;
1369
+ const CHECKPOINT_ID_PATTERN = /^spaceapp-checkpoint-\d{8}T\d{6}Z(?:-[a-f0-9]{8})?$/;
1170
1370
  const CHECKPOINT_KEEP_COUNT = 2;
1171
1371
 
1172
1372
  function checkpointId() {
1173
- return `spaceapp-checkpoint-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`;
1373
+ return `spaceapp-checkpoint-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}-${randomBytes(4).toString("hex")}`;
1174
1374
  }
1175
1375
 
1176
1376
  async function manifestEntry(path, logicalPath) {
@@ -1207,6 +1407,22 @@ async function collectFiles(directory, logicalDirectory) {
1207
1407
  return files;
1208
1408
  }
1209
1409
 
1410
+ async function createQuiescedCheckpoint(root, config, io) {
1411
+ io.stdout.write("Pausing SpaceApp writers before the database checkpoint...\n");
1412
+ try {
1413
+ io.stdout.write("Ensuring the existing PostgreSQL database is ready for the checkpoint...\n");
1414
+ const databaseCode = await io.execute(composeCommand("ensureCheckpointDatabase", root, config), io);
1415
+ if (databaseCode !== 0) throw new Error(`The existing PostgreSQL database did not become ready (exit ${databaseCode}). The upgrade was not applied.`);
1416
+ const code = await io.execute(composeCommand("stopForRestore", root, config), io);
1417
+ if (code !== 0) throw new Error(`Could not pause SpaceApp writers (exit ${code}).`);
1418
+ return await createCheckpoint(root, config, io);
1419
+ } catch (error) {
1420
+ const resumed = await io.execute(composeCommand("up", root, config), io).catch(() => 1);
1421
+ if (resumed !== 0) io.stderr.write("RECOVERY_REQUIRED: could not resume the unchanged runtime. Run doctor --fix.\n");
1422
+ throw error;
1423
+ }
1424
+ }
1425
+
1210
1426
  async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform }) {
1211
1427
  const id = checkpointId();
1212
1428
  const path = join(root, "checkpoints", id);
@@ -1234,18 +1450,22 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute,
1234
1450
  }
1235
1451
  stdout.write("Creating checkpoint (configuration, secrets, and database dump)...\n");
1236
1452
  const dumpPath = join(path, "postgres.dump");
1237
- const dumpCode = await execute(
1238
- composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1239
- { stdin: null, stdout: createWriteStream(dumpPath), stderr }
1240
- );
1241
- if (dumpCode !== 0) {
1242
- await rm(path, { recursive: true, force: true });
1243
- throw new Error(`Checkpoint database dump failed with Docker exit ${dumpCode}. No changes were made.`);
1244
- }
1453
+ const dumpStream=createWriteStream(dumpPath,{mode:0o600});
1454
+ await once(dumpStream,"open");
1455
+ let dumpCode;
1245
1456
  try {
1246
- await stat(dumpPath);
1247
- } catch {
1248
- await writeFile(dumpPath, "");
1457
+ dumpCode = await execute(
1458
+ composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1459
+ { stdin: null, stdout: dumpStream, stderr }
1460
+ );
1461
+ } finally {
1462
+ dumpStream.end();
1463
+ await once(dumpStream,"close");
1464
+ }
1465
+ const dumpSize=(await stat(dumpPath)).size;
1466
+ if (dumpCode !== 0 || dumpSize === 0) {
1467
+ await rm(path, { recursive: true, force: true });
1468
+ throw new Error(`Checkpoint database dump failed or was empty (exit ${dumpCode}). The upgrade was not applied.`);
1249
1469
  }
1250
1470
  manifest.files.push(await manifestEntry(dumpPath, "postgres.dump"));
1251
1471
  await writeFile(join(path, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
@@ -1266,6 +1486,21 @@ async function verifyCheckpoint(checkpoint) {
1266
1486
  return true;
1267
1487
  }
1268
1488
 
1489
+ async function retainFailedUpgrade(root, checkpoint, attemptedConfig, io) {
1490
+ // A partially started runtime may already have accepted owner writes. Never
1491
+ // replace its database automatically with an older snapshot.
1492
+ const stopCode = await io.execute(composeCommand("stopForRestore", root, attemptedConfig),
1493
+ {stdin:null,stdout:io.stdout,stderr:io.stderr}).catch(()=>1);
1494
+ await commitInstallation(root, attemptedConfig);
1495
+ const recovery = {status:"RECOVERY_REQUIRED",failedAt:new Date().toISOString(),
1496
+ previousVersion:checkpoint.manifest.version,attemptedVersion:attemptedConfig.version,
1497
+ checkpointPath:checkpoint.path,databasePreserved:true,writersStopped:stopCode===0};
1498
+ await writeFile(join(checkpoint.path,"recovery.json"),`${JSON.stringify(recovery,null,2)}\n`,{mode:0o600});
1499
+ io.stderr.write(`RECOVERY_REQUIRED: the current database and volumes were retained. Checkpoint: ${checkpoint.path}\n`);
1500
+ if(stopCode!==0)io.stderr.write("Could not stop all application writers. No database restore was attempted.\n");
1501
+ io.stderr.write(`Run "${UNIVERSAL_COMMAND} doctor --fix" to repair the attempted version. Restoring older data requires an explicit recovery decision.\n`);
1502
+ }
1503
+
1269
1504
  async function markCheckpointVerified(checkpoint) {
1270
1505
  await writeFile(
1271
1506
  join(checkpoint.path, "verified.json"),
@@ -1281,49 +1516,17 @@ async function pruneCheckpoints(currentPath) {
1281
1516
  .filter((entry) => entry.isDirectory() && CHECKPOINT_ID_PATTERN.test(entry.name))
1282
1517
  .map((entry) => join(parent, entry.name))
1283
1518
  .sort();
1284
- while (entries.length > CHECKPOINT_KEEP_COUNT) {
1285
- await rm(entries.shift(), { recursive: true, force: true });
1286
- }
1287
- }
1288
-
1289
- async function restoreCheckpoint(root, checkpoint, { stdin, stdout, stderr, execute, config }) {
1290
- if (!checkpoint) {
1291
- stderr.write("No checkpoint was available for restore.\n");
1292
- return false;
1293
- }
1294
- const verified = await verifyCheckpoint(checkpoint);
1295
- if (!verified) {
1296
- stderr.write(`Checkpoint ${checkpoint.id} failed verification; it remains at ${checkpoint.path} for manual recovery.\n`);
1297
- return false;
1298
- }
1299
- stdout.write(`Restoring checkpoint ${checkpoint.id}...\n`);
1300
- await execute(
1301
- composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1302
- { stdin: null, stdout: null, stderr: null }
1303
- ).catch(() => {});
1304
- const fileNames = ["config.json", "runtime.env", "compose.yml", "compose.workspaces.yml", "compose.host-access.yml"];
1305
- for (const fileName of fileNames) {
1306
- await copyFile(join(checkpoint.path, fileName), join(root, fileName));
1519
+ const verified = [];
1520
+ for (const entry of entries) {
1521
+ if (await stat(join(entry, "verified.json")).catch(() => null)) verified.push(entry);
1307
1522
  }
1308
- const restored = await loadConfig(root);
1309
- const dumpPath = join(checkpoint.path, "postgres.dump");
1310
- const dbCode = await execute(
1311
- composeCommand("checkpointRestore", root, { profile: restored.profile, companionsEnabled: restored.companionsEnabled }),
1312
- { stdin: createReadStream(dumpPath), stdout, stderr }
1313
- );
1314
- if (dbCode !== 0) {
1315
- stderr.write(`Checkpoint database restore failed with Docker exit ${dbCode}. Checkpoint remains at ${checkpoint.path}.\n`);
1316
- return false;
1523
+ while (verified.length > CHECKPOINT_KEEP_COUNT) {
1524
+ await rm(verified.shift(), { recursive: true, force: true });
1317
1525
  }
1318
- const upCode = await execute(
1319
- composeCommand("up", root, { profile: restored.profile, companionsEnabled: restored.companionsEnabled }),
1320
- { stdin, stdout, stderr }
1321
- );
1322
- return upCode === 0;
1323
1526
  }
1324
1527
 
1325
1528
  async function requireChangeApproval(stdin, stdout, stderr, lines) {
1326
- if (!interactiveAvailable(stdin)) {
1529
+ if (!interactiveAvailable(stdin) && !approvedAutomation(stdin)) {
1327
1530
  stderr.write("This change requires an interactive terminal (TTY). No changes were made.\n");
1328
1531
  return false;
1329
1532
  }
@@ -1394,18 +1597,9 @@ async function restoreRuntimeAfterFailedInstall({
1394
1597
  })
1395
1598
  );
1396
1599
  if (restoreCode === 0) {
1397
- if (existingConfig.profile === "light") {
1398
- const cleanupCode = await runtimeExecute(
1399
- composeCommand("removeBrowser", root, {
1400
- profile: "standard"
1401
- })
1402
- );
1403
- if (cleanupCode !== 0) {
1404
- stderr.write(
1405
- "The previous SpaceApp runtime was restored, but an inactive browser container may remain.\n"
1406
- );
1407
- }
1408
- }
1600
+ const cleanupCode = await removeInactiveProfileServices({root, config: existingConfig,
1601
+ execute: runtimeExecute, stdin, stdout, stderr});
1602
+ if (cleanupCode !== 0) stderr.write("The previous runtime was restored, but inactive service cleanup failed. Run doctor --fix.\n");
1409
1603
  stderr.write("The previous SpaceApp runtime and access mode were restored.\n");
1410
1604
  return;
1411
1605
  }
@@ -1467,18 +1661,18 @@ function parseInstallArgs(args) {
1467
1661
  continue;
1468
1662
  }
1469
1663
  throw new Error(
1470
- `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
1664
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
1471
1665
  );
1472
1666
  }
1473
1667
  if (
1474
- !["auto", "light", "standard"].includes(requestedProfile) ||
1668
+ !["auto", "small", "medium", "large", "light", "standard"].includes(requestedProfile) ||
1475
1669
  (
1476
1670
  requestedAccessMode !== undefined &&
1477
1671
  !["isolated", "host-root"].includes(requestedAccessMode)
1478
1672
  )
1479
1673
  ) {
1480
1674
  throw new Error(
1481
- `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
1675
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
1482
1676
  );
1483
1677
  }
1484
1678
  return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled };
@@ -1573,11 +1767,14 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
1573
1767
  }
1574
1768
 
1575
1769
  async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
1576
- if (args.length !== 2 || args[0] !== "install" || args[1] !== "claude") {
1577
- throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install claude`);
1578
- }
1579
- stdout.write("Installing Claude Code from Anthropic into this installation's private provider volume.\n");
1580
- return execute(composeCommand("installClaude", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr });
1770
+ if (args.length !== 2 || args[0] !== "install") {
1771
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install <provider>`);
1772
+ }
1773
+ const provider=args[1];
1774
+ stdout.write(`Installing optional provider ${provider} into this installation's private provider volume.\n`);
1775
+ // Keep the existing Claude path compatible with older runtime images.
1776
+ const action=provider === "claude" ? "installClaude" : "installProvider";
1777
+ return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider }), { stdin, stdout, stderr });
1581
1778
  }
1582
1779
 
1583
1780
  async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
@@ -1612,7 +1809,179 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
1612
1809
  });
1613
1810
  }
1614
1811
 
1615
- async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute }) {
1812
+ async function reinstallCommand({
1813
+ root,
1814
+ config,
1815
+ platform,
1816
+ stdin,
1817
+ stdout,
1818
+ stderr,
1819
+ execute,
1820
+ request,
1821
+ sleep
1822
+ }) {
1823
+ const approved = await requireChangeApproval(stdin, stdout, stderr, [
1824
+ `Clean setup SpaceApp at ${root}`,
1825
+ "This recreates managed files, templates, and runtime containers.",
1826
+ "Your database, persistent data, and credentials will be preserved."
1827
+ ]);
1828
+ if (!approved) {
1829
+ return 0;
1830
+ }
1831
+ const downCode = await execute(
1832
+ composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1833
+ { stdin, stdout, stderr }
1834
+ );
1835
+ if (downCode !== 0) {
1836
+ stderr.write(`Clean setup warning: container stop returned exit ${downCode}.\n`);
1837
+ }
1838
+ await writeRuntimeFiles(root, config);
1839
+ const upCode = await execute(
1840
+ composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1841
+ { stdin, stdout, stderr }
1842
+ );
1843
+ if (upCode !== 0) {
1844
+ stderr.write(`Clean setup failed during container startup (exit ${upCode}).\n`);
1845
+ return upCode;
1846
+ }
1847
+ if (typeof request === "function") {
1848
+ const url = `http://${config.bindHost}:${config.port}`;
1849
+ const ready = await waitForReadiness({
1850
+ url,
1851
+ request,
1852
+ sleep: sleep || wait,
1853
+ onProgress: async ({ elapsedSeconds }) => {
1854
+ stdout.write(`Waiting for SpaceApp readiness after clean setup (${elapsedSeconds}s)...\n`);
1855
+ }
1856
+ });
1857
+ if (!ready) {
1858
+ stderr.write("Clean setup completed container start, but readiness check timed out.\n");
1859
+ return 1;
1860
+ }
1861
+ }
1862
+ stdout.write("SpaceApp clean setup completed successfully. All data and settings preserved.\n");
1863
+ return 0;
1864
+ }
1865
+
1866
+ async function supportBundleCommand({
1867
+ root,
1868
+ config,
1869
+ platform,
1870
+ arch,
1871
+ stdout,
1872
+ stderr,
1873
+ execute,
1874
+ request,
1875
+ outPath
1876
+ }) {
1877
+ const bundle = {
1878
+ schemaVersion: "1.0.0",
1879
+ collectedAt: new Date().toISOString(),
1880
+ system: {
1881
+ platform,
1882
+ arch,
1883
+ nodeVersion: process.version,
1884
+ release: typeof process.release === "object" ? process.release.name : "unknown"
1885
+ },
1886
+ launcher: {
1887
+ version: PACKAGE_VERSION,
1888
+ runtimeVersion: RUNTIME_VERSION
1889
+ },
1890
+ installation: {
1891
+ root,
1892
+ config: config
1893
+ ? {
1894
+ version: config.version,
1895
+ profile: config.profile,
1896
+ accessMode: config.accessMode,
1897
+ companionsEnabled: config.companionsEnabled,
1898
+ bindHost: config.bindHost,
1899
+ port: config.port,
1900
+ previousVersion: config.previousVersion ?? null
1901
+ }
1902
+ : null
1903
+ },
1904
+ services: {},
1905
+ health: {}
1906
+ };
1907
+
1908
+ if (config) {
1909
+ const url = `http://${config.bindHost}:${config.port}`;
1910
+ if (typeof request === "function") {
1911
+ try {
1912
+ const res = await request(`${url}/readyz`, { method: "GET", signal: AbortSignal.timeout(3000) });
1913
+ bundle.health.readyz = { status: res.status, ok: res.ok };
1914
+ } catch (e) {
1915
+ bundle.health.readyz = { error: e.message };
1916
+ }
1917
+ }
1918
+ }
1919
+
1920
+ const destination = outPath || join(
1921
+ root,
1922
+ `support-bundle-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}.json`
1923
+ );
1924
+ await writeFile(destination, JSON.stringify(bundle, null, 2), { mode: 0o600 });
1925
+ stdout.write(`SpaceApp support bundle generated: ${destination}\n`);
1926
+ return 0;
1927
+ }
1928
+
1929
+ async function factoryResetCommand({
1930
+ root,
1931
+ config,
1932
+ platform,
1933
+ stdin,
1934
+ stdout,
1935
+ stderr,
1936
+ execute,
1937
+ runtimeVersion,
1938
+ persistSetupToken
1939
+ }) {
1940
+ stdout.write("\nWARNING: Factory reset will PERMANENTLY ERASE all local database records, persistent volumes, and configuration.\n");
1941
+ stdout.write("External workspaces outside the installation root are preserved.\n\n");
1942
+
1943
+ try {
1944
+ stdout.write("Creating safety backup before factory reset...\n");
1945
+ await execute(
1946
+ composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1947
+ { stdin, stdout, stderr }
1948
+ );
1949
+ } catch (err) {
1950
+ stdout.write(`Safety backup notice: ${err.message}. Continuing with reset confirmation.\n`);
1951
+ }
1952
+
1953
+ const confirm = await readSecret(
1954
+ stdin,
1955
+ stdout,
1956
+ "Type FACTORY-RESET to remove all data and reset SpaceApp: ",
1957
+ { mask: false }
1958
+ );
1959
+ if (confirm.trim() !== "FACTORY-RESET") {
1960
+ stdout.write("Factory reset cancelled. No data was erased.\n");
1961
+ return 0;
1962
+ }
1963
+
1964
+ await execute(
1965
+ composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
1966
+ { stdin, stdout, stderr }
1967
+ );
1968
+
1969
+ try {
1970
+ await rm(join(root, "config.json"), { force: true });
1971
+ await rm(join(root, "runtime.env"), { force: true });
1972
+ await rm(join(root, "compose.yml"), { force: true });
1973
+ } catch {}
1974
+
1975
+ const result = await initializeInstallation(root, { version: runtimeVersion });
1976
+ stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`);
1977
+ if (result.setupToken) {
1978
+ stdout.write(`One-time setup token: ${result.setupToken}\n`);
1979
+ stdout.write("Store it temporarily; it expires after first owner setup.\n");
1980
+ }
1981
+ return 0;
1982
+ }
1983
+
1984
+ async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources = inspectSystemResources, ensureDocker = ensureDockerAvailable, env = process.env, arch = process.arch }) {
1616
1985
  if (args.length > 1) {
1617
1986
  throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
1618
1987
  }
@@ -1620,7 +1989,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
1620
1989
  const path = upgradePath(config.version, targetVersion);
1621
1990
  const interactive = interactiveAvailable(stdin);
1622
1991
  const continuation = await readRunOnceContinuation(root, { platform, runtimeVersion: targetVersion });
1623
- if (!interactive && !continuation) {
1992
+ if (!interactive && !continuation && !approvedAutomation(stdin)) {
1624
1993
  stderr.write(
1625
1994
  "SpaceApp update changes require an interactive terminal (TTY). " +
1626
1995
  "Re-run from a terminal, or continue an approved Windows RunOnce plan.\n"
@@ -1646,7 +2015,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
1646
2015
  return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources });
1647
2016
  }
1648
2017
  }
1649
- return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute });
2018
+ return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources, ensureDocker, env, arch });
1650
2019
  }
1651
2020
  if (path === "downgrade") {
1652
2021
  if (!continuation) {
@@ -1675,16 +2044,23 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
1675
2044
  if (!continuation) {
1676
2045
  const approved = await finalConfirmation(stdin, stdout, [
1677
2046
  `Runtime image version: ${config.version} -> ${targetVersion}`,
1678
- "Checkpoint: configuration, secrets, and a database dump are saved before the change and restored automatically on failure.",
2047
+ "Checkpoint: configuration, secrets, and a verified database dump are saved before the change; current data is preserved if cutover fails.",
1679
2048
  "Downtime: containers restart during the cutover.",
1680
2049
  SPACEAPP_UPGRADE_POLICY.rollbackCapable
1681
- ? "Rollback: the previous runtime is restored automatically on failure; the previous version stays recorded for manual rollback."
2050
+ ? "Recovery: use doctor --fix after a failed cutover; the previous version and verified checkpoint remain available for explicit rollback."
1682
2051
  : "Rollback: not supported for this target version."
1683
2052
  ]);
1684
2053
  if (!approved) {
1685
2054
  return 0;
1686
2055
  }
1687
2056
  }
2057
+ const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute,
2058
+ installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } });
2059
+ if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code;
2060
+ const resources = await inspectResources(root);
2061
+ const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" });
2062
+ if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources,
2063
+ profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1;
1688
2064
  return performUpdate({
1689
2065
  root,
1690
2066
  config,
@@ -1694,10 +2070,25 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
1694
2070
  stdout,
1695
2071
  stderr,
1696
2072
  execute,
2073
+ request,
2074
+ sleep,
2075
+ inspectResources,
1697
2076
  preserveRecreate: path === "preserve-recreate"
1698
2077
  });
1699
2078
  }
1700
2079
 
2080
+ async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr }) {
2081
+ const resources = await inspectResources(root);
2082
+ const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute });
2083
+ const checks = storageChecks(plan, resources);
2084
+ for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`);
2085
+ if (checks.some((check) => !check.ok)) {
2086
+ stderr.write("Insufficient additional storage. Existing data and runtime have not been replaced; free space and retry.\n");
2087
+ return 1;
2088
+ }
2089
+ return 0;
2090
+ }
2091
+
1701
2092
  async function doctor({
1702
2093
  root,
1703
2094
  platform,
@@ -1707,13 +2098,33 @@ async function doctor({
1707
2098
  stdin,
1708
2099
  inspectResources,
1709
2100
  resources,
2101
+ profile,
2102
+ operation,
2103
+ storagePlan,
1710
2104
  dockerReady = false
1711
2105
  }) {
2106
+ const configuration = profile ? { ok: true, detail: "Prepared installation configuration" } : await loadConfig(root).then(() => ({ ok: true, detail: root })).catch((error) => ({ ok: false, detail: `${error.message}. Run install for a new setup, or doctor --fix for an existing setup.` }));
1712
2107
  const detectedResources = resources ?? await inspectResources(root);
2108
+ let resolvedProfile = profile;
2109
+ if (!resolvedProfile) {
2110
+ const existingConfig = await loadConfig(root).catch(() => null);
2111
+ if (existingConfig?.profile) {
2112
+ resolvedProfile = existingConfig.profile;
2113
+ } else if (detectedResources?.totalMemoryBytes) {
2114
+ resolvedProfile = resolveInstallProfile("auto", detectedResources);
2115
+ } else {
2116
+ resolvedProfile = "light";
2117
+ }
2118
+ }
2119
+ operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh");
2120
+ const resourceChecks = installResourceChecks(storagePlan?.resources ?? detectedResources, resolvedProfile, {
2121
+ operation, ...(storagePlan ? { requiredDiskBytes: storagePlan.requiredDiskBytes } : {})
2122
+ });
1713
2123
  const checks = [
1714
2124
  { name: "Node.js", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version },
1715
- { name: "Configuration", ok: true, detail: root },
1716
- ...installResourceChecks(detectedResources)
2125
+ { name: "Configuration", ...configuration },
2126
+ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"),
2127
+ ...(storagePlan ? storageChecks(storagePlan, detectedResources) : [])
1717
2128
  ];
1718
2129
  const dockerResults = [];
1719
2130
  for (const probe of [
@@ -1770,7 +2181,8 @@ async function waitForApplicationReady({
1770
2181
  request,
1771
2182
  sleep,
1772
2183
  onProgress,
1773
- maxAttempts = APPLICATION_READY_MAX_ATTEMPTS
2184
+ maxAttempts = APPLICATION_READY_MAX_ATTEMPTS,
2185
+ attemptTimeoutMs = APPLICATION_READY_ATTEMPT_TIMEOUT_MS
1774
2186
  }) {
1775
2187
  if (typeof request !== "function") {
1776
2188
  throw new Error("SpaceApp readiness requires a Fetch-compatible request function.");
@@ -1779,12 +2191,19 @@ async function waitForApplicationReady({
1779
2191
  const timeout = setTimeout(() => controller.abort(), APPLICATION_READY_WAIT_MS);
1780
2192
  try {
1781
2193
  for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
2194
+ const attemptController = new AbortController();
2195
+ const attemptTimeout = setTimeout(
2196
+ () => attemptController.abort(),
2197
+ attemptTimeoutMs
2198
+ );
2199
+ const onOverallAbort = () => attemptController.abort();
2200
+ controller.signal.addEventListener("abort", onOverallAbort, { once: true });
1782
2201
  try {
1783
2202
  const response = await request(`${url}/readyz`, {
1784
2203
  method: "GET",
1785
2204
  headers: { accept: "application/json" },
1786
2205
  redirect: "error",
1787
- signal: controller.signal
2206
+ signal: attemptController.signal
1788
2207
  });
1789
2208
  if (response?.ok) {
1790
2209
  const payload = await response.json();
@@ -1796,6 +2215,9 @@ async function waitForApplicationReady({
1796
2215
  if (controller.signal.aborted) {
1797
2216
  return false;
1798
2217
  }
2218
+ } finally {
2219
+ clearTimeout(attemptTimeout);
2220
+ controller.signal.removeEventListener("abort", onOverallAbort);
1799
2221
  }
1800
2222
  if (attempt < maxAttempts && !controller.signal.aborted) {
1801
2223
  await sleep(APPLICATION_READY_POLL_MS);
@@ -1816,6 +2238,8 @@ async function waitForApplicationReady({
1816
2238
  }
1817
2239
  }
1818
2240
 
2241
+ const waitForReadiness = waitForApplicationReady;
2242
+
1819
2243
  async function requestSetupStatus({ url, request }) {
1820
2244
  const controller = new AbortController();
1821
2245
  const timeout = setTimeout(() => controller.abort(), SETUP_STATUS_TIMEOUT_MS);
@@ -1859,131 +2283,9 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder
1859
2283
  return code;
1860
2284
  }
1861
2285
 
1862
- export async function readSecret(stdin, stdout, prompt, { mask = true } = {}) {
1863
- stdout.write(prompt);
1864
- if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
1865
- // Non-TTY stdin (e.g. piped input on Windows through npx.cmd). Read only
1866
- // up to the first newline so an interactive console that reports non-TTY
1867
- // stdin still works: previously this drained the stream to EOF, so typing
1868
- // "y" + Enter looked frozen and a second keystroke produced "y\ny" and
1869
- // "Please answer y or n." loops. When the stream ends before a newline
1870
- // (fully piped answers), everything read is returned as-is so "y\nn\n"
1871
- // piped input still yields successive valid answers. Event listeners are
1872
- // used instead of the Readable async iterator because an abandoned
1873
- // iterator can destroy the stream, breaking the next prompt.
1874
- const value = await new Promise((resolve) => {
1875
- let answer = "";
1876
- const finish = (result) => {
1877
- cleanup();
1878
- resolve(result);
1879
- };
1880
- // Paused-mode reads: consume exactly one line from the buffer, leaving
1881
- // the rest (and the stream itself) intact for the next prompt.
1882
- const onReadable = () => {
1883
- let chunk;
1884
- while ((chunk = stdin.read()) !== null) {
1885
- for (const character of String(chunk)) {
1886
- if (character === "\r" || character === "\n") {
1887
- finish(answer);
1888
- return;
1889
- }
1890
- answer += character;
1891
- }
1892
- }
1893
- };
1894
- const onEnd = () => finish(answer);
1895
- const onClose = () => finish(answer);
1896
- const cleanup = () => {
1897
- stdin.off("readable", onReadable);
1898
- stdin.off("end", onEnd);
1899
- stdin.off("close", onClose);
1900
- };
1901
- if (typeof stdin.read === "function" && typeof stdin.on === "function") {
1902
- stdin.on("readable", onReadable);
1903
- stdin.once("end", onEnd);
1904
- stdin.once("close", onClose);
1905
- onReadable();
1906
- } else {
1907
- // Fallback for exotic streams without readable-mode support.
1908
- const iterator = stdin[Symbol.asyncIterator]();
1909
- (async () => {
1910
- for (;;) {
1911
- const step = await iterator.next();
1912
- if (step.done) {
1913
- finish(answer);
1914
- return;
1915
- }
1916
- for (const character of String(step.value)) {
1917
- if (character === "\r" || character === "\n") {
1918
- finish(answer);
1919
- return;
1920
- }
1921
- answer += character;
1922
- }
1923
- }
1924
- })().catch(() => finish(answer));
1925
- }
1926
- });
1927
- stdout.write("\n");
1928
- return value;
1929
- }
1930
- if (process.platform === "win32") {
1931
- // Windows consoles (PowerShell 7 / ConPTY) do not reliably forward
1932
- // raw-mode keystrokes to native children: a "y" + Enter can be frozen,
1933
- // split across chunks, or doubled ("yy" -> "Please answer y or n.").
1934
- // Read cooked console lines without setRawMode instead; the console
1935
- // itself provides line editing and echo.
1936
- const rl = createInterface({ input: stdin, terminal: false, crlfDelay: Infinity });
1937
- const cooked = await new Promise((resolve) => {
1938
- let got = null;
1939
- rl.on("line", (line) => {
1940
- got = line;
1941
- rl.close();
1942
- });
1943
- rl.on("close", () => resolve(got ?? ""));
1944
- });
1945
- stdout.write("\n");
1946
- return cooked;
1947
- }
1948
- stdin.setRawMode(true);
1949
- stdin.resume();
1950
- stdin.setEncoding("utf8");
1951
- let value = "";
1952
- try {
1953
- // Drive the iterator manually instead of breaking a for-await loop:
1954
- // breaking a for-await early calls the Readable async iterator's return(),
1955
- // which DESTROYS the stream, so any later prompt would abort. Stopping
1956
- // without calling return() leaves the stream paused and intact.
1957
- const iterator = stdin[Symbol.asyncIterator]();
1958
- for (;;) {
1959
- const step = await iterator.next();
1960
- if (step.done) {
1961
- break;
1962
- }
1963
- for (const character of step.value) {
1964
- if (character === "\u0003") {
1965
- throw new Error("Input cancelled.");
1966
- }
1967
- if (character === "\r" || character === "\n") {
1968
- stdout.write("\n");
1969
- return value;
1970
- }
1971
- if (character === "\u007f") {
1972
- if (value.length > 0) {
1973
- value = value.slice(0, -1);
1974
- if (mask) stdout.write("\b \b");
1975
- }
1976
- continue;
1977
- }
1978
- value += character;
1979
- if (mask) stdout.write("*");
1980
- }
1981
- }
1982
- return value;
1983
- } finally {
1984
- stdin.setRawMode(false);
1985
- stdin.pause();
1986
- }
2286
+ export async function readSecret(stdin, stdout, prompt, { mask = true, answerKey = null, defaultValue = "" } = {}) {
2287
+ const controller = stdin?._spaceappPromptController || new PromptController({ stdin, stdout });
2288
+ return await controller.readLine(prompt, { mask, answerKey, defaultValue });
1987
2289
  }
1988
2290
 
1989
2291
  export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
@@ -2009,22 +2311,33 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
2009
2311
  const commandEnv = spec.env === undefined
2010
2312
  ? process.env
2011
2313
  : validateCommandEnvironment(spec.env);
2314
+ const background = spec.command === 'xdg-open' && spec.background === true;
2315
+ let launchTimer, commandTimer;
2012
2316
  const child = spawnTrustedCommand(spec, {
2013
2317
  env: commandEnv,
2014
2318
  shell: false,
2015
- stdio: [input === undefined ? (stdin || "inherit") : "pipe", stdout || "ignore", stderr || "ignore"]
2319
+ detached: background,
2320
+ stdio: background ? ['ignore','ignore','ignore'] : [input === undefined ? (stdin || "inherit") : "pipe", stdout && typeof stdout.fd !== "number" ? "pipe" : stdout || "ignore", stderr && typeof stderr.fd !== "number" ? "pipe" : stderr || "ignore"]
2016
2321
  });
2322
+ if (!background && child.stdout && stdout) child.stdout.pipe(stdout, { end: false });
2323
+ if (!background && child.stderr && stderr) child.stderr.pipe(stderr, { end: false });
2324
+ if (Number.isFinite(spec.timeoutMs) && spec.timeoutMs > 0) commandTimer = setTimeout(() => child.kill(), spec.timeoutMs);
2017
2325
  child.once("error", (error) => {
2326
+ clearTimeout(launchTimer);
2327
+ clearTimeout(commandTimer);
2018
2328
  if (error?.code === "ENOENT") {
2019
2329
  resolve(127);
2020
2330
  } else {
2021
2331
  reject(error);
2022
2332
  }
2023
2333
  });
2024
- child.once("exit", (code, signal) => {
2334
+ child.once("close", (code, signal) => {
2335
+ clearTimeout(launchTimer);
2336
+ clearTimeout(commandTimer);
2025
2337
  resolve(code ?? (signal ? 1 : 0));
2026
2338
  });
2027
- if (input !== undefined) {
2339
+ if(background)launchTimer=setTimeout(()=>{child.unref();resolve(0);},750);
2340
+ if (input !== undefined && !background) {
2028
2341
  child.stdin.end(input);
2029
2342
  }
2030
2343
  });
@@ -2075,7 +2388,7 @@ function openBrowser(url, platform, execute, io) {
2075
2388
  env: { SPACEAPP_OPEN_URL: url }
2076
2389
  }, io);
2077
2390
  }
2078
- return execute({ command: "xdg-open", args: [url] }, io);
2391
+ return execute({ command: "xdg-open", args: [url], background: true }, io);
2079
2392
  }
2080
2393
 
2081
2394
  function assertNoArgs(args, command) {
@@ -2140,28 +2453,64 @@ Usage: ${UNIVERSAL_COMMAND} <command>
2140
2453
  spaceapp <command> Optional global launcher
2141
2454
 
2142
2455
  init Create a local SpaceApp installation
2143
- install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
2456
+ install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
2144
2457
  Install prerequisites, initialize, and start
2458
+ reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
2459
+ Clean reinstallation preserving data and credentials
2460
+ repair [--dry-run] Verify and repair configuration and runtime files
2145
2461
  up | down | status | logs Manage the Docker application
2146
2462
  open Open the local web application
2147
- doctor Check resources, Docker, Compose, and engine
2463
+ doctor [--fix] Diagnose; --fix repairs existing config and runtime
2464
+ support-bundle Generate a diagnostic bundle JSON for support
2148
2465
  update [version] | rollback Update or roll back images
2149
2466
  backup | restore Back up or restore persistent state
2467
+ factory-reset Safety backup, purge all volumes/data, and reset
2150
2468
  workspace add <path> [--read-only]
2151
2469
  workspace remove <id-or-path>
2152
2470
  workspace list
2153
2471
  credentials set <provider> Read a credential from masked stdin
2154
2472
  credentials remove <provider>
2155
2473
  credentials list
2156
- provider install claude Owner-initiated Anthropic package install
2474
+ provider install <provider> Install an optional CLI in its persistent volume
2475
+ codex, gemini, qwen, kimi, grok, claude, deepseek,
2476
+ autohand, cursor, copilot (also installed on first use)
2157
2477
  owner reset-password Read the new password from masked stdin
2158
2478
  owner rotate-setup-token Replace an expired unclaimed setup token
2159
2479
  uninstall [--purge-data] Remove containers; keep data by default
2160
2480
 
2161
- Interactive setup wizard: install, update, init, rollback, and uninstall ask
2162
- questions and require a final confirmation before any change. Command flags
2163
- pre-select answers but never skip the confirmation. Without a TTY, changes
2164
- are refused; the only exception is an approved Windows RunOnce continuation
2165
- (bound to the user, installation root, and target version; expires in 24h).
2481
+ Options:
2482
+ --plan Dry-run mode: print actions without executing
2483
+ --json Output structured JSON where supported
2484
+ --log-file <file> Append all execution logs to specified file
2485
+ --local-images Use preloaded images only (offline/lab; missing images fail)
2486
+ --non-interactive Run non-interactively, failing on missing input
2487
+ --answers <json|path> Pre-seed answers; automation requires confirm:true
2488
+
2489
+ Profiles (auto uses CPU, RAM and free disk, then checks Docker resources):
2490
+ small: 4 CPUs, 7 GiB usable RAM, 6.5 GiB fresh space; SpaceApp + OpenCode.
2491
+ Background integration workers and managed browser are disabled.
2492
+ medium: 4 CPUs, 11 GiB usable RAM, 8 GiB fresh space; adds background workers.
2493
+ large: 8 CPUs, 15 GiB usable RAM, 11 GiB fresh space; adds managed browser.
2494
+ light/standard: compatibility settings for existing installations.
2495
+ Upgrades keep the installed profile unless you explicitly choose another one.
2496
+ Upgrade space: missing layers + database checkpoint + 0.5 GiB reserve.
2497
+ Cached target images need no extra image space; existing data stays in place.
2498
+ Unknown layer sizes use a conservative estimate; space is rechecked after pull.
2499
+ No automatic deletion of images, volumes, workspaces, or backups to free space.
2500
+
2501
+ Quick start: install (automatic profile, OpenCode first, browser opens on completion).
2502
+ Windows PowerShell: use npx.cmd if script execution policy blocks npx.ps1.
2503
+ The npx command requires Node.js 20.11+ installed; Docker is installed if missing.
2504
+ Data lives in SPACEAPP_HOME (config/secrets) and persistent Docker volumes.
2505
+ Use backup before major changes; uninstall preserves data unless --purge-data.
2506
+ Never use factory-reset to solve an ordinary installation problem.
2507
+
2508
+ Automation: --non-interactive --answers '{"confirm":true,"profile":"auto","access":"isolated","companions":false,"telemetry":false,"open":false}'
2509
+ For shells with different quoting, save that JSON to a file and pass --answers file.json.
2510
+ Use help or <command> --help from any directory, including before installation.
2511
+
2512
+ Interactive setup wizard: install, reinstall, update, init, rollback, repair,
2513
+ factory-reset, and uninstall ask questions and require a final confirmation
2514
+ before any change. Command flags pre-select answers; unattended changes require explicit confirm:true.
2166
2515
  `;
2167
2516
  }