run-spaceapp 1.0.2 → 1.0.31
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 +634 -288
- package/src/index.mjs +90 -28
- 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 +11 -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 writeRuntimeFiles(
|
|
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 writeRuntimeFiles(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 writeRuntimeFiles(root, updated);
|
|
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,19 @@ 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
|
+
const code = await io.execute(composeCommand("stopForRestore", root, config), io);
|
|
1414
|
+
if (code !== 0) throw new Error(`Could not pause SpaceApp writers (exit ${code}).`);
|
|
1415
|
+
return await createCheckpoint(root, config, io);
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
const resumed = await io.execute(composeCommand("up", root, config), io).catch(() => 1);
|
|
1418
|
+
if (resumed !== 0) io.stderr.write("RECOVERY_REQUIRED: could not resume the unchanged runtime. Run doctor --fix.\n");
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1210
1423
|
async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform }) {
|
|
1211
1424
|
const id = checkpointId();
|
|
1212
1425
|
const path = join(root, "checkpoints", id);
|
|
@@ -1234,18 +1447,22 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute,
|
|
|
1234
1447
|
}
|
|
1235
1448
|
stdout.write("Creating checkpoint (configuration, secrets, and database dump)...\n");
|
|
1236
1449
|
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
|
-
}
|
|
1450
|
+
const dumpStream=createWriteStream(dumpPath,{mode:0o600});
|
|
1451
|
+
await once(dumpStream,"open");
|
|
1452
|
+
let dumpCode;
|
|
1245
1453
|
try {
|
|
1246
|
-
await
|
|
1247
|
-
|
|
1248
|
-
|
|
1454
|
+
dumpCode = await execute(
|
|
1455
|
+
composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1456
|
+
{ stdin: null, stdout: dumpStream, stderr }
|
|
1457
|
+
);
|
|
1458
|
+
} finally {
|
|
1459
|
+
dumpStream.end();
|
|
1460
|
+
await once(dumpStream,"close");
|
|
1461
|
+
}
|
|
1462
|
+
const dumpSize=(await stat(dumpPath)).size;
|
|
1463
|
+
if (dumpCode !== 0 || dumpSize === 0) {
|
|
1464
|
+
await rm(path, { recursive: true, force: true });
|
|
1465
|
+
throw new Error(`Checkpoint database dump failed or was empty (exit ${dumpCode}). The upgrade was not applied.`);
|
|
1249
1466
|
}
|
|
1250
1467
|
manifest.files.push(await manifestEntry(dumpPath, "postgres.dump"));
|
|
1251
1468
|
await writeFile(join(path, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
@@ -1266,6 +1483,21 @@ async function verifyCheckpoint(checkpoint) {
|
|
|
1266
1483
|
return true;
|
|
1267
1484
|
}
|
|
1268
1485
|
|
|
1486
|
+
async function retainFailedUpgrade(root, checkpoint, attemptedConfig, io) {
|
|
1487
|
+
// A partially started runtime may already have accepted owner writes. Never
|
|
1488
|
+
// replace its database automatically with an older snapshot.
|
|
1489
|
+
const stopCode = await io.execute(composeCommand("stopForRestore", root, attemptedConfig),
|
|
1490
|
+
{stdin:null,stdout:io.stdout,stderr:io.stderr}).catch(()=>1);
|
|
1491
|
+
await commitInstallation(root, attemptedConfig);
|
|
1492
|
+
const recovery = {status:"RECOVERY_REQUIRED",failedAt:new Date().toISOString(),
|
|
1493
|
+
previousVersion:checkpoint.manifest.version,attemptedVersion:attemptedConfig.version,
|
|
1494
|
+
checkpointPath:checkpoint.path,databasePreserved:true,writersStopped:stopCode===0};
|
|
1495
|
+
await writeFile(join(checkpoint.path,"recovery.json"),`${JSON.stringify(recovery,null,2)}\n`,{mode:0o600});
|
|
1496
|
+
io.stderr.write(`RECOVERY_REQUIRED: the current database and volumes were retained. Checkpoint: ${checkpoint.path}\n`);
|
|
1497
|
+
if(stopCode!==0)io.stderr.write("Could not stop all application writers. No database restore was attempted.\n");
|
|
1498
|
+
io.stderr.write(`Run "${UNIVERSAL_COMMAND} doctor --fix" to repair the attempted version. Restoring older data requires an explicit recovery decision.\n`);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1269
1501
|
async function markCheckpointVerified(checkpoint) {
|
|
1270
1502
|
await writeFile(
|
|
1271
1503
|
join(checkpoint.path, "verified.json"),
|
|
@@ -1281,49 +1513,17 @@ async function pruneCheckpoints(currentPath) {
|
|
|
1281
1513
|
.filter((entry) => entry.isDirectory() && CHECKPOINT_ID_PATTERN.test(entry.name))
|
|
1282
1514
|
.map((entry) => join(parent, entry.name))
|
|
1283
1515
|
.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));
|
|
1516
|
+
const verified = [];
|
|
1517
|
+
for (const entry of entries) {
|
|
1518
|
+
if (await stat(join(entry, "verified.json")).catch(() => null)) verified.push(entry);
|
|
1307
1519
|
}
|
|
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;
|
|
1520
|
+
while (verified.length > CHECKPOINT_KEEP_COUNT) {
|
|
1521
|
+
await rm(verified.shift(), { recursive: true, force: true });
|
|
1317
1522
|
}
|
|
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
1523
|
}
|
|
1324
1524
|
|
|
1325
1525
|
async function requireChangeApproval(stdin, stdout, stderr, lines) {
|
|
1326
|
-
if (!interactiveAvailable(stdin)) {
|
|
1526
|
+
if (!interactiveAvailable(stdin) && !approvedAutomation(stdin)) {
|
|
1327
1527
|
stderr.write("This change requires an interactive terminal (TTY). No changes were made.\n");
|
|
1328
1528
|
return false;
|
|
1329
1529
|
}
|
|
@@ -1394,18 +1594,9 @@ async function restoreRuntimeAfterFailedInstall({
|
|
|
1394
1594
|
})
|
|
1395
1595
|
);
|
|
1396
1596
|
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
|
-
}
|
|
1597
|
+
const cleanupCode = await removeInactiveProfileServices({root, config: existingConfig,
|
|
1598
|
+
execute: runtimeExecute, stdin, stdout, stderr});
|
|
1599
|
+
if (cleanupCode !== 0) stderr.write("The previous runtime was restored, but inactive service cleanup failed. Run doctor --fix.\n");
|
|
1409
1600
|
stderr.write("The previous SpaceApp runtime and access mode were restored.\n");
|
|
1410
1601
|
return;
|
|
1411
1602
|
}
|
|
@@ -1467,18 +1658,18 @@ function parseInstallArgs(args) {
|
|
|
1467
1658
|
continue;
|
|
1468
1659
|
}
|
|
1469
1660
|
throw new Error(
|
|
1470
|
-
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
|
|
1661
|
+
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
|
|
1471
1662
|
);
|
|
1472
1663
|
}
|
|
1473
1664
|
if (
|
|
1474
|
-
!["auto", "light", "standard"].includes(requestedProfile) ||
|
|
1665
|
+
!["auto", "small", "medium", "large", "light", "standard"].includes(requestedProfile) ||
|
|
1475
1666
|
(
|
|
1476
1667
|
requestedAccessMode !== undefined &&
|
|
1477
1668
|
!["isolated", "host-root"].includes(requestedAccessMode)
|
|
1478
1669
|
)
|
|
1479
1670
|
) {
|
|
1480
1671
|
throw new Error(
|
|
1481
|
-
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
|
|
1672
|
+
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]`
|
|
1482
1673
|
);
|
|
1483
1674
|
}
|
|
1484
1675
|
return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled };
|
|
@@ -1573,11 +1764,14 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
|
|
|
1573
1764
|
}
|
|
1574
1765
|
|
|
1575
1766
|
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
|
-
|
|
1767
|
+
if (args.length !== 2 || args[0] !== "install") {
|
|
1768
|
+
throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install <provider>`);
|
|
1769
|
+
}
|
|
1770
|
+
const provider=args[1];
|
|
1771
|
+
stdout.write(`Installing optional provider ${provider} into this installation's private provider volume.\n`);
|
|
1772
|
+
// Keep the existing Claude path compatible with older runtime images.
|
|
1773
|
+
const action=provider === "claude" ? "installClaude" : "installProvider";
|
|
1774
|
+
return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider }), { stdin, stdout, stderr });
|
|
1581
1775
|
}
|
|
1582
1776
|
|
|
1583
1777
|
async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
|
|
@@ -1612,7 +1806,179 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
|
|
|
1612
1806
|
});
|
|
1613
1807
|
}
|
|
1614
1808
|
|
|
1615
|
-
async function
|
|
1809
|
+
async function reinstallCommand({
|
|
1810
|
+
root,
|
|
1811
|
+
config,
|
|
1812
|
+
platform,
|
|
1813
|
+
stdin,
|
|
1814
|
+
stdout,
|
|
1815
|
+
stderr,
|
|
1816
|
+
execute,
|
|
1817
|
+
request,
|
|
1818
|
+
sleep
|
|
1819
|
+
}) {
|
|
1820
|
+
const approved = await requireChangeApproval(stdin, stdout, stderr, [
|
|
1821
|
+
`Clean setup SpaceApp at ${root}`,
|
|
1822
|
+
"This recreates managed files, templates, and runtime containers.",
|
|
1823
|
+
"Your database, persistent data, and credentials will be preserved."
|
|
1824
|
+
]);
|
|
1825
|
+
if (!approved) {
|
|
1826
|
+
return 0;
|
|
1827
|
+
}
|
|
1828
|
+
const downCode = await execute(
|
|
1829
|
+
composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1830
|
+
{ stdin, stdout, stderr }
|
|
1831
|
+
);
|
|
1832
|
+
if (downCode !== 0) {
|
|
1833
|
+
stderr.write(`Clean setup warning: container stop returned exit ${downCode}.\n`);
|
|
1834
|
+
}
|
|
1835
|
+
await writeRuntimeFiles(root, config);
|
|
1836
|
+
const upCode = await execute(
|
|
1837
|
+
composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1838
|
+
{ stdin, stdout, stderr }
|
|
1839
|
+
);
|
|
1840
|
+
if (upCode !== 0) {
|
|
1841
|
+
stderr.write(`Clean setup failed during container startup (exit ${upCode}).\n`);
|
|
1842
|
+
return upCode;
|
|
1843
|
+
}
|
|
1844
|
+
if (typeof request === "function") {
|
|
1845
|
+
const url = `http://${config.bindHost}:${config.port}`;
|
|
1846
|
+
const ready = await waitForReadiness({
|
|
1847
|
+
url,
|
|
1848
|
+
request,
|
|
1849
|
+
sleep: sleep || wait,
|
|
1850
|
+
onProgress: async ({ elapsedSeconds }) => {
|
|
1851
|
+
stdout.write(`Waiting for SpaceApp readiness after clean setup (${elapsedSeconds}s)...\n`);
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
if (!ready) {
|
|
1855
|
+
stderr.write("Clean setup completed container start, but readiness check timed out.\n");
|
|
1856
|
+
return 1;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
stdout.write("SpaceApp clean setup completed successfully. All data and settings preserved.\n");
|
|
1860
|
+
return 0;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async function supportBundleCommand({
|
|
1864
|
+
root,
|
|
1865
|
+
config,
|
|
1866
|
+
platform,
|
|
1867
|
+
arch,
|
|
1868
|
+
stdout,
|
|
1869
|
+
stderr,
|
|
1870
|
+
execute,
|
|
1871
|
+
request,
|
|
1872
|
+
outPath
|
|
1873
|
+
}) {
|
|
1874
|
+
const bundle = {
|
|
1875
|
+
schemaVersion: "1.0.0",
|
|
1876
|
+
collectedAt: new Date().toISOString(),
|
|
1877
|
+
system: {
|
|
1878
|
+
platform,
|
|
1879
|
+
arch,
|
|
1880
|
+
nodeVersion: process.version,
|
|
1881
|
+
release: typeof process.release === "object" ? process.release.name : "unknown"
|
|
1882
|
+
},
|
|
1883
|
+
launcher: {
|
|
1884
|
+
version: PACKAGE_VERSION,
|
|
1885
|
+
runtimeVersion: RUNTIME_VERSION
|
|
1886
|
+
},
|
|
1887
|
+
installation: {
|
|
1888
|
+
root,
|
|
1889
|
+
config: config
|
|
1890
|
+
? {
|
|
1891
|
+
version: config.version,
|
|
1892
|
+
profile: config.profile,
|
|
1893
|
+
accessMode: config.accessMode,
|
|
1894
|
+
companionsEnabled: config.companionsEnabled,
|
|
1895
|
+
bindHost: config.bindHost,
|
|
1896
|
+
port: config.port,
|
|
1897
|
+
previousVersion: config.previousVersion ?? null
|
|
1898
|
+
}
|
|
1899
|
+
: null
|
|
1900
|
+
},
|
|
1901
|
+
services: {},
|
|
1902
|
+
health: {}
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
if (config) {
|
|
1906
|
+
const url = `http://${config.bindHost}:${config.port}`;
|
|
1907
|
+
if (typeof request === "function") {
|
|
1908
|
+
try {
|
|
1909
|
+
const res = await request(`${url}/readyz`, { method: "GET", signal: AbortSignal.timeout(3000) });
|
|
1910
|
+
bundle.health.readyz = { status: res.status, ok: res.ok };
|
|
1911
|
+
} catch (e) {
|
|
1912
|
+
bundle.health.readyz = { error: e.message };
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const destination = outPath || join(
|
|
1918
|
+
root,
|
|
1919
|
+
`support-bundle-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}.json`
|
|
1920
|
+
);
|
|
1921
|
+
await writeFile(destination, JSON.stringify(bundle, null, 2), { mode: 0o600 });
|
|
1922
|
+
stdout.write(`SpaceApp support bundle generated: ${destination}\n`);
|
|
1923
|
+
return 0;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function factoryResetCommand({
|
|
1927
|
+
root,
|
|
1928
|
+
config,
|
|
1929
|
+
platform,
|
|
1930
|
+
stdin,
|
|
1931
|
+
stdout,
|
|
1932
|
+
stderr,
|
|
1933
|
+
execute,
|
|
1934
|
+
runtimeVersion,
|
|
1935
|
+
persistSetupToken
|
|
1936
|
+
}) {
|
|
1937
|
+
stdout.write("\nWARNING: Factory reset will PERMANENTLY ERASE all local database records, persistent volumes, and configuration.\n");
|
|
1938
|
+
stdout.write("External workspaces outside the installation root are preserved.\n\n");
|
|
1939
|
+
|
|
1940
|
+
try {
|
|
1941
|
+
stdout.write("Creating safety backup before factory reset...\n");
|
|
1942
|
+
await execute(
|
|
1943
|
+
composeCommand("backup", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1944
|
+
{ stdin, stdout, stderr }
|
|
1945
|
+
);
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
stdout.write(`Safety backup notice: ${err.message}. Continuing with reset confirmation.\n`);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const confirm = await readSecret(
|
|
1951
|
+
stdin,
|
|
1952
|
+
stdout,
|
|
1953
|
+
"Type FACTORY-RESET to remove all data and reset SpaceApp: ",
|
|
1954
|
+
{ mask: false }
|
|
1955
|
+
);
|
|
1956
|
+
if (confirm.trim() !== "FACTORY-RESET") {
|
|
1957
|
+
stdout.write("Factory reset cancelled. No data was erased.\n");
|
|
1958
|
+
return 0;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
await execute(
|
|
1962
|
+
composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1963
|
+
{ stdin, stdout, stderr }
|
|
1964
|
+
);
|
|
1965
|
+
|
|
1966
|
+
try {
|
|
1967
|
+
await rm(join(root, "config.json"), { force: true });
|
|
1968
|
+
await rm(join(root, "runtime.env"), { force: true });
|
|
1969
|
+
await rm(join(root, "compose.yml"), { force: true });
|
|
1970
|
+
} catch {}
|
|
1971
|
+
|
|
1972
|
+
const result = await initializeInstallation(root, { version: runtimeVersion });
|
|
1973
|
+
stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`);
|
|
1974
|
+
if (result.setupToken) {
|
|
1975
|
+
stdout.write(`One-time setup token: ${result.setupToken}\n`);
|
|
1976
|
+
stdout.write("Store it temporarily; it expires after first owner setup.\n");
|
|
1977
|
+
}
|
|
1978
|
+
return 0;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
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
1982
|
if (args.length > 1) {
|
|
1617
1983
|
throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
|
|
1618
1984
|
}
|
|
@@ -1620,7 +1986,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
|
|
|
1620
1986
|
const path = upgradePath(config.version, targetVersion);
|
|
1621
1987
|
const interactive = interactiveAvailable(stdin);
|
|
1622
1988
|
const continuation = await readRunOnceContinuation(root, { platform, runtimeVersion: targetVersion });
|
|
1623
|
-
if (!interactive && !continuation) {
|
|
1989
|
+
if (!interactive && !continuation && !approvedAutomation(stdin)) {
|
|
1624
1990
|
stderr.write(
|
|
1625
1991
|
"SpaceApp update changes require an interactive terminal (TTY). " +
|
|
1626
1992
|
"Re-run from a terminal, or continue an approved Windows RunOnce plan.\n"
|
|
@@ -1646,7 +2012,7 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
|
|
|
1646
2012
|
return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources });
|
|
1647
2013
|
}
|
|
1648
2014
|
}
|
|
1649
|
-
return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute });
|
|
2015
|
+
return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute, request, sleep, inspectResources, ensureDocker, env, arch });
|
|
1650
2016
|
}
|
|
1651
2017
|
if (path === "downgrade") {
|
|
1652
2018
|
if (!continuation) {
|
|
@@ -1675,16 +2041,23 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
|
|
|
1675
2041
|
if (!continuation) {
|
|
1676
2042
|
const approved = await finalConfirmation(stdin, stdout, [
|
|
1677
2043
|
`Runtime image version: ${config.version} -> ${targetVersion}`,
|
|
1678
|
-
"Checkpoint: configuration, secrets, and a database dump are saved before the change
|
|
2044
|
+
"Checkpoint: configuration, secrets, and a verified database dump are saved before the change; current data is preserved if cutover fails.",
|
|
1679
2045
|
"Downtime: containers restart during the cutover.",
|
|
1680
2046
|
SPACEAPP_UPGRADE_POLICY.rollbackCapable
|
|
1681
|
-
? "
|
|
2047
|
+
? "Recovery: use doctor --fix after a failed cutover; the previous version and verified checkpoint remain available for explicit rollback."
|
|
1682
2048
|
: "Rollback: not supported for this target version."
|
|
1683
2049
|
]);
|
|
1684
2050
|
if (!approved) {
|
|
1685
2051
|
return 0;
|
|
1686
2052
|
}
|
|
1687
2053
|
}
|
|
2054
|
+
const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute,
|
|
2055
|
+
installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } });
|
|
2056
|
+
if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code;
|
|
2057
|
+
const resources = await inspectResources(root);
|
|
2058
|
+
const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" });
|
|
2059
|
+
if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources,
|
|
2060
|
+
profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1;
|
|
1688
2061
|
return performUpdate({
|
|
1689
2062
|
root,
|
|
1690
2063
|
config,
|
|
@@ -1694,10 +2067,25 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
|
|
|
1694
2067
|
stdout,
|
|
1695
2068
|
stderr,
|
|
1696
2069
|
execute,
|
|
2070
|
+
request,
|
|
2071
|
+
sleep,
|
|
2072
|
+
inspectResources,
|
|
1697
2073
|
preserveRecreate: path === "preserve-recreate"
|
|
1698
2074
|
});
|
|
1699
2075
|
}
|
|
1700
2076
|
|
|
2077
|
+
async function verifyStorage({ root, config, existingConfig, operation, inspectResources, execute, stdout, stderr }) {
|
|
2078
|
+
const resources = await inspectResources(root);
|
|
2079
|
+
const plan = await inspectStoragePlan({ root, config, existingConfig, operation, resources, execute });
|
|
2080
|
+
const checks = storageChecks(plan, resources);
|
|
2081
|
+
for (const check of checks) (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`);
|
|
2082
|
+
if (checks.some((check) => !check.ok)) {
|
|
2083
|
+
stderr.write("Insufficient additional storage. Existing data and runtime have not been replaced; free space and retry.\n");
|
|
2084
|
+
return 1;
|
|
2085
|
+
}
|
|
2086
|
+
return 0;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1701
2089
|
async function doctor({
|
|
1702
2090
|
root,
|
|
1703
2091
|
platform,
|
|
@@ -1707,13 +2095,33 @@ async function doctor({
|
|
|
1707
2095
|
stdin,
|
|
1708
2096
|
inspectResources,
|
|
1709
2097
|
resources,
|
|
2098
|
+
profile,
|
|
2099
|
+
operation,
|
|
2100
|
+
storagePlan,
|
|
1710
2101
|
dockerReady = false
|
|
1711
2102
|
}) {
|
|
2103
|
+
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
2104
|
const detectedResources = resources ?? await inspectResources(root);
|
|
2105
|
+
let resolvedProfile = profile;
|
|
2106
|
+
if (!resolvedProfile) {
|
|
2107
|
+
const existingConfig = await loadConfig(root).catch(() => null);
|
|
2108
|
+
if (existingConfig?.profile) {
|
|
2109
|
+
resolvedProfile = existingConfig.profile;
|
|
2110
|
+
} else if (detectedResources?.totalMemoryBytes) {
|
|
2111
|
+
resolvedProfile = resolveInstallProfile("auto", detectedResources);
|
|
2112
|
+
} else {
|
|
2113
|
+
resolvedProfile = "light";
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
operation ??= await loadConfig(root).then(() => "maintenance").catch(() => "fresh");
|
|
2117
|
+
const resourceChecks = installResourceChecks(storagePlan?.resources ?? detectedResources, resolvedProfile, {
|
|
2118
|
+
operation, ...(storagePlan ? { requiredDiskBytes: storagePlan.requiredDiskBytes } : {})
|
|
2119
|
+
});
|
|
1713
2120
|
const checks = [
|
|
1714
2121
|
{ name: "Node.js", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version },
|
|
1715
|
-
{ name: "Configuration",
|
|
1716
|
-
...
|
|
2122
|
+
{ name: "Configuration", ...configuration },
|
|
2123
|
+
...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"),
|
|
2124
|
+
...(storagePlan ? storageChecks(storagePlan, detectedResources) : [])
|
|
1717
2125
|
];
|
|
1718
2126
|
const dockerResults = [];
|
|
1719
2127
|
for (const probe of [
|
|
@@ -1770,7 +2178,8 @@ async function waitForApplicationReady({
|
|
|
1770
2178
|
request,
|
|
1771
2179
|
sleep,
|
|
1772
2180
|
onProgress,
|
|
1773
|
-
maxAttempts = APPLICATION_READY_MAX_ATTEMPTS
|
|
2181
|
+
maxAttempts = APPLICATION_READY_MAX_ATTEMPTS,
|
|
2182
|
+
attemptTimeoutMs = APPLICATION_READY_ATTEMPT_TIMEOUT_MS
|
|
1774
2183
|
}) {
|
|
1775
2184
|
if (typeof request !== "function") {
|
|
1776
2185
|
throw new Error("SpaceApp readiness requires a Fetch-compatible request function.");
|
|
@@ -1779,12 +2188,19 @@ async function waitForApplicationReady({
|
|
|
1779
2188
|
const timeout = setTimeout(() => controller.abort(), APPLICATION_READY_WAIT_MS);
|
|
1780
2189
|
try {
|
|
1781
2190
|
for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
|
|
2191
|
+
const attemptController = new AbortController();
|
|
2192
|
+
const attemptTimeout = setTimeout(
|
|
2193
|
+
() => attemptController.abort(),
|
|
2194
|
+
attemptTimeoutMs
|
|
2195
|
+
);
|
|
2196
|
+
const onOverallAbort = () => attemptController.abort();
|
|
2197
|
+
controller.signal.addEventListener("abort", onOverallAbort, { once: true });
|
|
1782
2198
|
try {
|
|
1783
2199
|
const response = await request(`${url}/readyz`, {
|
|
1784
2200
|
method: "GET",
|
|
1785
2201
|
headers: { accept: "application/json" },
|
|
1786
2202
|
redirect: "error",
|
|
1787
|
-
signal:
|
|
2203
|
+
signal: attemptController.signal
|
|
1788
2204
|
});
|
|
1789
2205
|
if (response?.ok) {
|
|
1790
2206
|
const payload = await response.json();
|
|
@@ -1796,6 +2212,9 @@ async function waitForApplicationReady({
|
|
|
1796
2212
|
if (controller.signal.aborted) {
|
|
1797
2213
|
return false;
|
|
1798
2214
|
}
|
|
2215
|
+
} finally {
|
|
2216
|
+
clearTimeout(attemptTimeout);
|
|
2217
|
+
controller.signal.removeEventListener("abort", onOverallAbort);
|
|
1799
2218
|
}
|
|
1800
2219
|
if (attempt < maxAttempts && !controller.signal.aborted) {
|
|
1801
2220
|
await sleep(APPLICATION_READY_POLL_MS);
|
|
@@ -1816,6 +2235,8 @@ async function waitForApplicationReady({
|
|
|
1816
2235
|
}
|
|
1817
2236
|
}
|
|
1818
2237
|
|
|
2238
|
+
const waitForReadiness = waitForApplicationReady;
|
|
2239
|
+
|
|
1819
2240
|
async function requestSetupStatus({ url, request }) {
|
|
1820
2241
|
const controller = new AbortController();
|
|
1821
2242
|
const timeout = setTimeout(() => controller.abort(), SETUP_STATUS_TIMEOUT_MS);
|
|
@@ -1859,131 +2280,9 @@ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stder
|
|
|
1859
2280
|
return code;
|
|
1860
2281
|
}
|
|
1861
2282
|
|
|
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
|
-
}
|
|
2283
|
+
export async function readSecret(stdin, stdout, prompt, { mask = true, answerKey = null, defaultValue = "" } = {}) {
|
|
2284
|
+
const controller = stdin?._spaceappPromptController || new PromptController({ stdin, stdout });
|
|
2285
|
+
return await controller.readLine(prompt, { mask, answerKey, defaultValue });
|
|
1987
2286
|
}
|
|
1988
2287
|
|
|
1989
2288
|
export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
|
|
@@ -2009,22 +2308,33 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
|
|
|
2009
2308
|
const commandEnv = spec.env === undefined
|
|
2010
2309
|
? process.env
|
|
2011
2310
|
: validateCommandEnvironment(spec.env);
|
|
2311
|
+
const background = spec.command === 'xdg-open' && spec.background === true;
|
|
2312
|
+
let launchTimer, commandTimer;
|
|
2012
2313
|
const child = spawnTrustedCommand(spec, {
|
|
2013
2314
|
env: commandEnv,
|
|
2014
2315
|
shell: false,
|
|
2015
|
-
|
|
2316
|
+
detached: background,
|
|
2317
|
+
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
2318
|
});
|
|
2319
|
+
if (!background && child.stdout && stdout) child.stdout.pipe(stdout, { end: false });
|
|
2320
|
+
if (!background && child.stderr && stderr) child.stderr.pipe(stderr, { end: false });
|
|
2321
|
+
if (Number.isFinite(spec.timeoutMs) && spec.timeoutMs > 0) commandTimer = setTimeout(() => child.kill(), spec.timeoutMs);
|
|
2017
2322
|
child.once("error", (error) => {
|
|
2323
|
+
clearTimeout(launchTimer);
|
|
2324
|
+
clearTimeout(commandTimer);
|
|
2018
2325
|
if (error?.code === "ENOENT") {
|
|
2019
2326
|
resolve(127);
|
|
2020
2327
|
} else {
|
|
2021
2328
|
reject(error);
|
|
2022
2329
|
}
|
|
2023
2330
|
});
|
|
2024
|
-
child.once("
|
|
2331
|
+
child.once("close", (code, signal) => {
|
|
2332
|
+
clearTimeout(launchTimer);
|
|
2333
|
+
clearTimeout(commandTimer);
|
|
2025
2334
|
resolve(code ?? (signal ? 1 : 0));
|
|
2026
2335
|
});
|
|
2027
|
-
if
|
|
2336
|
+
if(background)launchTimer=setTimeout(()=>{child.unref();resolve(0);},750);
|
|
2337
|
+
if (input !== undefined && !background) {
|
|
2028
2338
|
child.stdin.end(input);
|
|
2029
2339
|
}
|
|
2030
2340
|
});
|
|
@@ -2075,7 +2385,7 @@ function openBrowser(url, platform, execute, io) {
|
|
|
2075
2385
|
env: { SPACEAPP_OPEN_URL: url }
|
|
2076
2386
|
}, io);
|
|
2077
2387
|
}
|
|
2078
|
-
return execute({ command: "xdg-open", args: [url] }, io);
|
|
2388
|
+
return execute({ command: "xdg-open", args: [url], background: true }, io);
|
|
2079
2389
|
}
|
|
2080
2390
|
|
|
2081
2391
|
function assertNoArgs(args, command) {
|
|
@@ -2140,28 +2450,64 @@ Usage: ${UNIVERSAL_COMMAND} <command>
|
|
|
2140
2450
|
spaceapp <command> Optional global launcher
|
|
2141
2451
|
|
|
2142
2452
|
init Create a local SpaceApp installation
|
|
2143
|
-
install [--profile auto|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
|
|
2453
|
+
install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
|
|
2144
2454
|
Install prerequisites, initialize, and start
|
|
2455
|
+
reinstall [--keep-data] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]
|
|
2456
|
+
Clean reinstallation preserving data and credentials
|
|
2457
|
+
repair [--dry-run] Verify and repair configuration and runtime files
|
|
2145
2458
|
up | down | status | logs Manage the Docker application
|
|
2146
2459
|
open Open the local web application
|
|
2147
|
-
doctor
|
|
2460
|
+
doctor [--fix] Diagnose; --fix repairs existing config and runtime
|
|
2461
|
+
support-bundle Generate a diagnostic bundle JSON for support
|
|
2148
2462
|
update [version] | rollback Update or roll back images
|
|
2149
2463
|
backup | restore Back up or restore persistent state
|
|
2464
|
+
factory-reset Safety backup, purge all volumes/data, and reset
|
|
2150
2465
|
workspace add <path> [--read-only]
|
|
2151
2466
|
workspace remove <id-or-path>
|
|
2152
2467
|
workspace list
|
|
2153
2468
|
credentials set <provider> Read a credential from masked stdin
|
|
2154
2469
|
credentials remove <provider>
|
|
2155
2470
|
credentials list
|
|
2156
|
-
provider install
|
|
2471
|
+
provider install <provider> Install an optional CLI in its persistent volume
|
|
2472
|
+
codex, gemini, qwen, kimi, grok, claude, deepseek,
|
|
2473
|
+
autohand, cursor, copilot (also installed on first use)
|
|
2157
2474
|
owner reset-password Read the new password from masked stdin
|
|
2158
2475
|
owner rotate-setup-token Replace an expired unclaimed setup token
|
|
2159
2476
|
uninstall [--purge-data] Remove containers; keep data by default
|
|
2160
2477
|
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2478
|
+
Options:
|
|
2479
|
+
--plan Dry-run mode: print actions without executing
|
|
2480
|
+
--json Output structured JSON where supported
|
|
2481
|
+
--log-file <file> Append all execution logs to specified file
|
|
2482
|
+
--local-images Use preloaded images only (offline/lab; missing images fail)
|
|
2483
|
+
--non-interactive Run non-interactively, failing on missing input
|
|
2484
|
+
--answers <json|path> Pre-seed answers; automation requires confirm:true
|
|
2485
|
+
|
|
2486
|
+
Profiles (auto uses CPU, RAM and free disk, then checks Docker resources):
|
|
2487
|
+
small: 4 CPUs, 7 GiB usable RAM, 6.5 GiB fresh space; SpaceApp + OpenCode.
|
|
2488
|
+
Background integration workers and managed browser are disabled.
|
|
2489
|
+
medium: 4 CPUs, 11 GiB usable RAM, 8 GiB fresh space; adds background workers.
|
|
2490
|
+
large: 8 CPUs, 15 GiB usable RAM, 11 GiB fresh space; adds managed browser.
|
|
2491
|
+
light/standard: compatibility settings for existing installations.
|
|
2492
|
+
Upgrades keep the installed profile unless you explicitly choose another one.
|
|
2493
|
+
Upgrade space: missing layers + database checkpoint + 0.5 GiB reserve.
|
|
2494
|
+
Cached target images need no extra image space; existing data stays in place.
|
|
2495
|
+
Unknown layer sizes use a conservative estimate; space is rechecked after pull.
|
|
2496
|
+
No automatic deletion of images, volumes, workspaces, or backups to free space.
|
|
2497
|
+
|
|
2498
|
+
Quick start: install (automatic profile, OpenCode first, browser opens on completion).
|
|
2499
|
+
Windows PowerShell: use npx.cmd if script execution policy blocks npx.ps1.
|
|
2500
|
+
The npx command requires Node.js 20.11+ installed; Docker is installed if missing.
|
|
2501
|
+
Data lives in SPACEAPP_HOME (config/secrets) and persistent Docker volumes.
|
|
2502
|
+
Use backup before major changes; uninstall preserves data unless --purge-data.
|
|
2503
|
+
Never use factory-reset to solve an ordinary installation problem.
|
|
2504
|
+
|
|
2505
|
+
Automation: --non-interactive --answers '{"confirm":true,"profile":"auto","access":"isolated","companions":false,"telemetry":false,"open":false}'
|
|
2506
|
+
For shells with different quoting, save that JSON to a file and pass --answers file.json.
|
|
2507
|
+
Use help or <command> --help from any directory, including before installation.
|
|
2508
|
+
|
|
2509
|
+
Interactive setup wizard: install, reinstall, update, init, rollback, repair,
|
|
2510
|
+
factory-reset, and uninstall ask questions and require a final confirmation
|
|
2511
|
+
before any change. Command flags pre-select answers; unattended changes require explicit confirm:true.
|
|
2166
2512
|
`;
|
|
2167
2513
|
}
|