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/README.md +23 -5
- package/package.json +4 -4
- package/src/cli.mjs +637 -288
- package/src/index.mjs +94 -30
- package/src/prerequisites.mjs +36 -11
- package/src/prompt-controller.mjs +344 -0
- package/src/storage.mjs +159 -0
- package/src/support-manifest.json +147 -0
- package/templates/compose.yml +5 -1
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
908
|
-
{ label: "
|
|
909
|
-
{ label: "
|
|
910
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1319
|
+
const stagedRoot = await mkdtemp(join(tmpdir(), "spaceapp-update-"));
|
|
1320
|
+
let checkpoint = null;
|
|
1126
1321
|
try {
|
|
1127
|
-
await
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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"
|
|
1577
|
-
throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
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
|
|
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
|
-
? "
|
|
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",
|
|
1716
|
-
...
|
|
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:
|
|
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
|
|
1864
|
-
|
|
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
|
-
|
|
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("
|
|
2334
|
+
child.once("close", (code, signal) => {
|
|
2335
|
+
clearTimeout(launchTimer);
|
|
2336
|
+
clearTimeout(commandTimer);
|
|
2025
2337
|
resolve(code ?? (signal ? 1 : 0));
|
|
2026
2338
|
});
|
|
2027
|
-
if
|
|
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
|
|
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
|
|
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
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|
}
|