run-spaceapp 0.1.23 → 0.1.25
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 +4 -1
- package/package.json +3 -3
- package/src/cli.mjs +787 -30
- package/src/index.mjs +129 -4
package/README.md
CHANGED
|
@@ -35,7 +35,10 @@ open. SpaceApp waits for up to ten minutes and continues automatically when
|
|
|
35
35
|
Docker is ready. If WSL2 requires one restart, SpaceApp registers a one-time
|
|
36
36
|
resume and asks before scheduling it. Sign back in after Windows restarts; the
|
|
37
37
|
same install continues automatically in Command Prompt without entering a
|
|
38
|
-
second command.
|
|
38
|
+
second command. Headless Windows sessions (SSH, CI services, session 0) also
|
|
39
|
+
work: during image pulls the launcher switches Docker to a temporary
|
|
40
|
+
credential-free config so Docker Desktop's graphical credential helper is never
|
|
41
|
+
invoked outside an interactive logon.
|
|
39
42
|
|
|
40
43
|
The launcher defaults to the `light` profile on every system. Run
|
|
41
44
|
`npx --yes run-spaceapp@latest install --profile standard` explicitly when
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-spaceapp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"spaceappRuntimeVersion": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
|
+
"spaceappRuntimeVersion": "0.1.25",
|
|
5
5
|
"spaceappHostRootRuntimeCompatible": true,
|
|
6
6
|
"description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -52,5 +52,5 @@
|
|
|
52
52
|
"access": "public",
|
|
53
53
|
"provenance": true
|
|
54
54
|
},
|
|
55
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "8b17f8fccaab3554f660ddf5b009b89358587edd"
|
|
56
56
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
copyFile,
|
|
6
|
+
mkdir,
|
|
7
|
+
mkdtemp,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
writeFile
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { tmpdir, userInfo } from "node:os";
|
|
5
15
|
import { join } from "node:path";
|
|
6
16
|
import process from "node:process";
|
|
7
17
|
import {
|
|
8
18
|
addWorkspace,
|
|
19
|
+
applyConfigRepairs,
|
|
9
20
|
commitInstallation,
|
|
10
21
|
composeCommand,
|
|
11
22
|
credentialProviders,
|
|
@@ -13,14 +24,17 @@ import {
|
|
|
13
24
|
inspectSystemResources,
|
|
14
25
|
installResourceChecks,
|
|
15
26
|
loadConfig,
|
|
27
|
+
planConfigRepairs,
|
|
28
|
+
prepareInstallation,
|
|
16
29
|
removeCredential,
|
|
17
30
|
removeWorkspace,
|
|
18
|
-
prepareInstallation,
|
|
19
31
|
resolveInstallAccessMode,
|
|
20
32
|
resolveInstallProfile,
|
|
21
33
|
resolveSpaceAppHome,
|
|
22
34
|
saveConfig,
|
|
23
35
|
selectLatestBackupId,
|
|
36
|
+
SPACEAPP_UPGRADE_POLICY,
|
|
37
|
+
upgradePath,
|
|
24
38
|
writeCredential,
|
|
25
39
|
writeRuntimeFiles,
|
|
26
40
|
writeSetupToken
|
|
@@ -68,6 +82,159 @@ const trustedEnvironmentNames = new Set([
|
|
|
68
82
|
"SPACEAPP_RESUME_SCRIPT_PATH"
|
|
69
83
|
]);
|
|
70
84
|
|
|
85
|
+
export async function withHeadlessDockerConfig(platform, spec, run) {
|
|
86
|
+
if (platform !== "win32") {
|
|
87
|
+
return run(spec);
|
|
88
|
+
}
|
|
89
|
+
const configDir = await mkdtemp(join(tmpdir(), "spaceapp-docker-config-"));
|
|
90
|
+
await writeFile(
|
|
91
|
+
join(configDir, "config.json"),
|
|
92
|
+
JSON.stringify({ auths: {} }),
|
|
93
|
+
{ mode: 0o600 }
|
|
94
|
+
);
|
|
95
|
+
try {
|
|
96
|
+
return await run({
|
|
97
|
+
...spec,
|
|
98
|
+
args: ["--config", configDir, ...spec.args]
|
|
99
|
+
});
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(configDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function interactiveAvailable(stdin) {
|
|
106
|
+
return Boolean(stdin?.isTTY && typeof stdin?.setRawMode === "function");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function promptYesNo(stdin, stdout, question, { defaultYes = false } = {}) {
|
|
110
|
+
for (;;) {
|
|
111
|
+
const answer = (await readSecret(stdin, stdout, `${question} [${defaultYes ? "Y/n" : "y/N"}] `, { mask: false })).trim().toLowerCase();
|
|
112
|
+
if (answer === "") {
|
|
113
|
+
return defaultYes;
|
|
114
|
+
}
|
|
115
|
+
if (answer === "y" || answer === "yes") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (answer === "n" || answer === "no") {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
stdout.write("Please answer y or n.\n");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function promptChoice(stdin, stdout, question, options, { defaultIndex = 0 } = {}) {
|
|
126
|
+
stdout.write(`${question}\n`);
|
|
127
|
+
options.forEach((option, index) => {
|
|
128
|
+
stdout.write(` [${index + 1}] ${option.label}\n`);
|
|
129
|
+
});
|
|
130
|
+
for (;;) {
|
|
131
|
+
const answer = (await readSecret(stdin, stdout, `Select 1-${options.length} [${defaultIndex + 1}]: `, { mask: false })).trim();
|
|
132
|
+
if (answer === "") {
|
|
133
|
+
return options[defaultIndex].value;
|
|
134
|
+
}
|
|
135
|
+
const index = Number.parseInt(answer, 10);
|
|
136
|
+
if (Number.isInteger(index) && index >= 1 && index <= options.length) {
|
|
137
|
+
return options[index - 1].value;
|
|
138
|
+
}
|
|
139
|
+
stdout.write(`Invalid choice. Enter a number between 1 and ${options.length}.\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function finalConfirmation(stdin, stdout, lines) {
|
|
144
|
+
stdout.write("SpaceApp setup plan:\n");
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
stdout.write(` - ${line}\n`);
|
|
147
|
+
}
|
|
148
|
+
const approved = await promptYesNo(stdin, stdout, "Apply this plan?", { defaultYes: false });
|
|
149
|
+
if (!approved) {
|
|
150
|
+
stdout.write("Cancelled. No changes were made.\n");
|
|
151
|
+
}
|
|
152
|
+
return approved;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readRawConfig(root) {
|
|
156
|
+
let raw;
|
|
157
|
+
try {
|
|
158
|
+
raw = JSON.parse(await readFile(join(root, "config.json"), "utf8"));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error?.code === "ENOENT") {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
raw = null;
|
|
164
|
+
}
|
|
165
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return raw;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const RUNONCE_CONTINUATION_LIFETIME_MS = 24 * 60 * 60 * 1_000;
|
|
172
|
+
|
|
173
|
+
function currentOsUsername({ platform, env }) {
|
|
174
|
+
if (platform === "win32") {
|
|
175
|
+
return env.USERNAME || env.USER || userInfo().username;
|
|
176
|
+
}
|
|
177
|
+
return env.USER || userInfo().username;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runOnceContinuationPath(root) {
|
|
181
|
+
return join(root, "var", "runonce-continuation.json");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readRunOnceContinuation(root, { platform, runtimeVersion }) {
|
|
185
|
+
if (platform !== "win32") {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
let payload;
|
|
189
|
+
try {
|
|
190
|
+
payload = JSON.parse(await readFile(runOnceContinuationPath(root), "utf8"));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code === "ENOENT") {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
!payload ||
|
|
199
|
+
typeof payload !== "object" ||
|
|
200
|
+
payload.targetVersion !== runtimeVersion ||
|
|
201
|
+
typeof payload.nonce !== "string" ||
|
|
202
|
+
payload.nonce.length < 32 ||
|
|
203
|
+
!payload.actions ||
|
|
204
|
+
typeof payload.actions !== "object" ||
|
|
205
|
+
Array.isArray(payload.actions) ||
|
|
206
|
+
payload.installRoot !== root ||
|
|
207
|
+
payload.user !== currentOsUsername({ platform, env: process.env }) ||
|
|
208
|
+
!Number.isFinite(payload.createdAt) ||
|
|
209
|
+
!Number.isFinite(payload.expiresAt) ||
|
|
210
|
+
payload.expiresAt - payload.createdAt > RUNONCE_CONTINUATION_LIFETIME_MS ||
|
|
211
|
+
Date.now() > payload.expiresAt
|
|
212
|
+
) {
|
|
213
|
+
await rm(runOnceContinuationPath(root), { force: true }).catch(() => {});
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return payload;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function consumeRunOnceContinuation(root) {
|
|
220
|
+
await rm(runOnceContinuationPath(root), { force: true }).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function writeRunOnceContinuation(root, { runtimeVersion, actions }) {
|
|
224
|
+
await mkdir(join(root, "var"), { recursive: true, mode: 0o700 });
|
|
225
|
+
const createdAt = Date.now();
|
|
226
|
+
const payload = {
|
|
227
|
+
user: currentOsUsername({ platform: "win32", env: process.env }),
|
|
228
|
+
installRoot: root,
|
|
229
|
+
targetVersion: runtimeVersion,
|
|
230
|
+
nonce: randomBytes(32).toString("base64url"),
|
|
231
|
+
createdAt,
|
|
232
|
+
expiresAt: createdAt + RUNONCE_CONTINUATION_LIFETIME_MS,
|
|
233
|
+
actions
|
|
234
|
+
};
|
|
235
|
+
await writeFile(runOnceContinuationPath(root), `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
236
|
+
}
|
|
237
|
+
|
|
71
238
|
export async function run(argv, {
|
|
72
239
|
env = process.env,
|
|
73
240
|
platform = process.platform,
|
|
@@ -123,6 +290,13 @@ export async function run(argv, {
|
|
|
123
290
|
}
|
|
124
291
|
if (command === "init") {
|
|
125
292
|
assertNoArgs(args, "init");
|
|
293
|
+
const approved = await requireChangeApproval(stdin, stdout, stderr, [
|
|
294
|
+
`Create a new SpaceApp installation at ${root}`,
|
|
295
|
+
"This writes configuration, secrets, and runtime files."
|
|
296
|
+
]);
|
|
297
|
+
if (!approved) {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
126
300
|
const result = await initializeInstallation(root, { version: runtimeVersion });
|
|
127
301
|
stdout.write(`SpaceApp initialized at ${root}\n`);
|
|
128
302
|
if (result.setupToken) {
|
|
@@ -180,6 +354,7 @@ export async function run(argv, {
|
|
|
180
354
|
root,
|
|
181
355
|
config,
|
|
182
356
|
version: runtimeVersion,
|
|
357
|
+
platform,
|
|
183
358
|
stdin,
|
|
184
359
|
stdout,
|
|
185
360
|
stderr,
|
|
@@ -191,13 +366,24 @@ export async function run(argv, {
|
|
|
191
366
|
if (!config.previousVersion) {
|
|
192
367
|
throw new Error("No previous SpaceApp version is recorded.");
|
|
193
368
|
}
|
|
369
|
+
const approved = await requireChangeApproval(stdin, stdout, stderr, [
|
|
370
|
+
`Runtime image version: ${config.version} -> ${config.previousVersion}`,
|
|
371
|
+
"Rollback restores the previously recorded runtime images and configuration."
|
|
372
|
+
]);
|
|
373
|
+
if (!approved) {
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
194
376
|
const rollback = {
|
|
195
377
|
...config,
|
|
196
378
|
version: config.previousVersion,
|
|
197
379
|
previousVersion: config.version
|
|
198
380
|
};
|
|
199
381
|
await writeRuntimeFiles(root, rollback);
|
|
200
|
-
const pullCode = await
|
|
382
|
+
const pullCode = await withHeadlessDockerConfig(
|
|
383
|
+
platform,
|
|
384
|
+
composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }),
|
|
385
|
+
(pullSpec) => runtimeExecute(pullSpec, { stdin, stdout, stderr })
|
|
386
|
+
);
|
|
201
387
|
if (pullCode !== 0) {
|
|
202
388
|
await writeRuntimeFiles(root, config);
|
|
203
389
|
return pullCode;
|
|
@@ -240,6 +426,14 @@ export async function run(argv, {
|
|
|
240
426
|
}
|
|
241
427
|
if (command === "uninstall") {
|
|
242
428
|
if (args.length === 0) {
|
|
429
|
+
const approved = await requireChangeApproval(stdin, stdout, stderr, [
|
|
430
|
+
"Stop and remove SpaceApp containers and network.",
|
|
431
|
+
"Data, configuration, secrets, and backups remain at the installation root.",
|
|
432
|
+
"Docker volumes are retained; the global SpaceApp CLI remains installed."
|
|
433
|
+
]);
|
|
434
|
+
if (!approved) {
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
243
437
|
stdout.write("Stopping and removing SpaceApp containers and network...\n");
|
|
244
438
|
const code = await runtimeExecute(
|
|
245
439
|
composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
@@ -303,7 +497,27 @@ async function installCommand(args, {
|
|
|
303
497
|
sleep,
|
|
304
498
|
persistSetupToken
|
|
305
499
|
}) {
|
|
306
|
-
const
|
|
500
|
+
const wizard = await planInteractiveSetup({
|
|
501
|
+
args,
|
|
502
|
+
root,
|
|
503
|
+
runtimeVersion,
|
|
504
|
+
platform,
|
|
505
|
+
stdin,
|
|
506
|
+
stdout,
|
|
507
|
+
stderr,
|
|
508
|
+
execute,
|
|
509
|
+
inspectResources
|
|
510
|
+
});
|
|
511
|
+
if (wizard.exit !== undefined) {
|
|
512
|
+
return wizard.exit;
|
|
513
|
+
}
|
|
514
|
+
const {
|
|
515
|
+
requestedProfile,
|
|
516
|
+
requestedAccessMode,
|
|
517
|
+
noOpen,
|
|
518
|
+
companionsEnabled,
|
|
519
|
+
telemetryEnabled
|
|
520
|
+
} = wizard;
|
|
307
521
|
const existingConfig = await loadExistingInstallation(root);
|
|
308
522
|
const accessMode = resolveInstallAccessMode(
|
|
309
523
|
requestedAccessMode,
|
|
@@ -331,6 +545,9 @@ async function installCommand(args, {
|
|
|
331
545
|
accessMode,
|
|
332
546
|
...(companionsEnabled ? { companionsEnabled } : {})
|
|
333
547
|
});
|
|
548
|
+
if (telemetryEnabled && !existingConfig) {
|
|
549
|
+
result.config = { ...result.config, telemetry: true };
|
|
550
|
+
}
|
|
334
551
|
|
|
335
552
|
stdout.write(`Launcher version: ${launcherVersion}\n`);
|
|
336
553
|
stdout.write(
|
|
@@ -442,11 +659,15 @@ async function installCommand(args, {
|
|
|
442
659
|
companionsEnabled,
|
|
443
660
|
...options
|
|
444
661
|
});
|
|
445
|
-
const pullCode = await
|
|
446
|
-
|
|
662
|
+
const pullCode = await withHeadlessDockerConfig(
|
|
663
|
+
platform,
|
|
447
664
|
stagedComposeCommand("pull"),
|
|
448
|
-
|
|
449
|
-
|
|
665
|
+
(pullSpec) => executeWithDockerDiagnostics(
|
|
666
|
+
execute,
|
|
667
|
+
pullSpec,
|
|
668
|
+
{ stdin, stdout, stderr },
|
|
669
|
+
{ platform, stderr }
|
|
670
|
+
)
|
|
450
671
|
);
|
|
451
672
|
if (pullCode !== 0) return pullCode;
|
|
452
673
|
runtimeMutationAttempted = true;
|
|
@@ -623,6 +844,478 @@ async function installCommand(args, {
|
|
|
623
844
|
}
|
|
624
845
|
}
|
|
625
846
|
|
|
847
|
+
async function planInteractiveSetup({
|
|
848
|
+
args,
|
|
849
|
+
root,
|
|
850
|
+
runtimeVersion,
|
|
851
|
+
platform,
|
|
852
|
+
stdin,
|
|
853
|
+
stdout,
|
|
854
|
+
stderr,
|
|
855
|
+
execute,
|
|
856
|
+
inspectResources
|
|
857
|
+
}) {
|
|
858
|
+
const parsed = parseInstallArgs(args);
|
|
859
|
+
const rawExisting = await readRawConfig(root);
|
|
860
|
+
const repairs = planConfigRepairs(rawExisting);
|
|
861
|
+
const path = upgradePath(rawExisting?.version ?? null, runtimeVersion);
|
|
862
|
+
const interactive = interactiveAvailable(stdin);
|
|
863
|
+
const continuation = await readRunOnceContinuation(root, { platform, runtimeVersion });
|
|
864
|
+
|
|
865
|
+
if (!interactive && !continuation) {
|
|
866
|
+
stderr.write(
|
|
867
|
+
"SpaceApp setup changes require an interactive terminal (TTY). " +
|
|
868
|
+
"Re-run from a terminal, or continue an approved Windows RunOnce plan.\n"
|
|
869
|
+
);
|
|
870
|
+
return { exit: 1 };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const resources = await inspectResources(root);
|
|
874
|
+
const resolveProfileChoice = (choice) => {
|
|
875
|
+
const resolved = resolveInstallProfile(choice, resources.totalMemoryBytes);
|
|
876
|
+
return { resolved, display: choice === "auto" ? `auto (${resolved})` : resolved };
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
if (continuation) {
|
|
880
|
+
await consumeRunOnceContinuation(root);
|
|
881
|
+
stdout.write("Continuing the approved unattended SpaceApp setup plan.\n");
|
|
882
|
+
await applyApprovedConfigRepairs(root, rawExisting);
|
|
883
|
+
const actions = continuation.actions ?? {};
|
|
884
|
+
return {
|
|
885
|
+
requestedProfile: actions.profile ?? "auto",
|
|
886
|
+
requestedAccessMode: actions.accessMode,
|
|
887
|
+
noOpen: actions.noOpen !== false,
|
|
888
|
+
companionsEnabled: actions.companionsEnabled ?? false,
|
|
889
|
+
telemetryEnabled: actions.telemetry ?? false
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (path === "fresh") {
|
|
894
|
+
const profileChoice = await promptChoice(stdin, stdout, "Which installation profile?", [
|
|
895
|
+
{ label: `auto (light profile on this system; ${formatGibibytes(resources.totalMemoryBytes)} GiB detected)`, value: "auto" },
|
|
896
|
+
{ label: "light (smaller footprint)", value: "light" },
|
|
897
|
+
{ label: "standard (full features, incl. managed browser)", value: "standard" }
|
|
898
|
+
], { defaultIndex: ["auto", "light", "standard"].indexOf(parsed.requestedProfile) });
|
|
899
|
+
const accessChoice = await promptChoice(stdin, stdout, "Which access mode?", [
|
|
900
|
+
{ label: "isolated (recommended; no host access)", value: "isolated" },
|
|
901
|
+
{ label: "host-root (Linux only; CLI sessions can read and modify the whole host)", value: "host-root" }
|
|
902
|
+
], { defaultIndex: parsed.requestedAccessMode === "host-root" ? 1 : 0 });
|
|
903
|
+
const companionsChoice = await promptYesNo(stdin, stdout, "Enable companion integrations (Claude Code, browser companions)?", { defaultYes: parsed.companionsEnabled });
|
|
904
|
+
const telemetryChoice = await promptYesNo(stdin, stdout, "Enable anonymous usage telemetry?", { defaultYes: false });
|
|
905
|
+
let dockerChoice = true;
|
|
906
|
+
if (platform === "win32") {
|
|
907
|
+
const dockerProbe = await detectDockerAvailable({ execute });
|
|
908
|
+
if (dockerProbe !== 0) {
|
|
909
|
+
dockerChoice = await promptYesNo(stdin, stdout, "Docker Engine was not detected. Include automatic Docker installation?", { defaultYes: true });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const openBrowserChoice = await promptYesNo(stdin, stdout, "Open the web application when installation completes?", { defaultYes: !parsed.noOpen });
|
|
913
|
+
|
|
914
|
+
const { resolved } = resolveProfileChoice(profileChoice);
|
|
915
|
+
const approved = await finalConfirmation(stdin, stdout, [
|
|
916
|
+
`Installation root: ${root}`,
|
|
917
|
+
`Runtime image version: not installed -> ${runtimeVersion}`,
|
|
918
|
+
`Profile: ${profileChoice === "auto" ? `auto (${resolved})` : profileChoice}`,
|
|
919
|
+
`Access mode: ${accessChoice}`,
|
|
920
|
+
`Companions: ${companionsChoice ? "enabled" : "disabled"}`,
|
|
921
|
+
`Telemetry: ${telemetryChoice ? "enabled" : "disabled"}`,
|
|
922
|
+
`Docker installation: ${dockerChoice ? "automatic if missing" : "not included"}`,
|
|
923
|
+
`Open browser afterwards: ${openBrowserChoice ? "yes" : "no"}`,
|
|
924
|
+
"Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes."
|
|
925
|
+
]);
|
|
926
|
+
if (!approved) {
|
|
927
|
+
return { exit: 0 };
|
|
928
|
+
}
|
|
929
|
+
if (platform === "win32") {
|
|
930
|
+
await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, {
|
|
931
|
+
profile: profileChoice,
|
|
932
|
+
accessMode: accessChoice,
|
|
933
|
+
companionsEnabled: companionsChoice,
|
|
934
|
+
telemetry: telemetryChoice,
|
|
935
|
+
noOpen: !openBrowserChoice
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
requestedProfile: profileChoice,
|
|
940
|
+
requestedAccessMode: accessChoice,
|
|
941
|
+
noOpen: !openBrowserChoice,
|
|
942
|
+
companionsEnabled: companionsChoice,
|
|
943
|
+
telemetryEnabled: telemetryChoice
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (path === "same" && !hasRequestedConfigChange(rawExisting, parsed)) {
|
|
948
|
+
const choice = await promptChoice(stdin, stdout, `SpaceApp ${rawExisting.version} is already installed. What would you like to do?`, [
|
|
949
|
+
{ label: "Run doctor diagnostics (read-only)", value: "doctor" },
|
|
950
|
+
{ label: "Repair the runtime (recreate containers from the current configuration)", value: "repair" },
|
|
951
|
+
{ label: "Cancel", value: "cancel" }
|
|
952
|
+
]);
|
|
953
|
+
if (choice === "cancel") {
|
|
954
|
+
stdout.write("Cancelled. No changes were made.\n");
|
|
955
|
+
return { exit: 0 };
|
|
956
|
+
}
|
|
957
|
+
if (choice === "doctor") {
|
|
958
|
+
return { exit: await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources }) };
|
|
959
|
+
}
|
|
960
|
+
await applyApprovedConfigRepairs(root, rawExisting);
|
|
961
|
+
const config = await loadExistingInstallation(root);
|
|
962
|
+
return { exit: await repairRuntime({ root, config, platform, stdin, stdout, stderr, execute }) };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (path === "downgrade") {
|
|
966
|
+
stderr.write(
|
|
967
|
+
`WARNING: target ${runtimeVersion} is OLDER than the installed ${rawExisting.version}.\n` +
|
|
968
|
+
"A downgrade can lose data created by the newer version.\n"
|
|
969
|
+
);
|
|
970
|
+
const typed = await readSecret(stdin, stdout, "Type DOWNGRADE to proceed with the controlled rollback: ", { mask: false });
|
|
971
|
+
if (typed !== "DOWNGRADE") {
|
|
972
|
+
stdout.write("Cancelled. No changes were made.\n");
|
|
973
|
+
return { exit: 0 };
|
|
974
|
+
}
|
|
975
|
+
} else if (path === "unsupported") {
|
|
976
|
+
stderr.write(
|
|
977
|
+
`Installed version ${rawExisting.version} is older than the minimum supported upgrade source ` +
|
|
978
|
+
`${SPACEAPP_UPGRADE_POLICY.minSupportedSourceVersion}. Make a backup, then reinstall from scratch.\n`
|
|
979
|
+
);
|
|
980
|
+
return { exit: 1 };
|
|
981
|
+
} else if (path === "unknown") {
|
|
982
|
+
stderr.write(
|
|
983
|
+
`Installed version ${rawExisting.version} cannot be compared with target ${runtimeVersion}. Installation cancelled.\n`
|
|
984
|
+
);
|
|
985
|
+
return { exit: 1 };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const repairLines = repairs.actions.map((action) => `Config repair: ${action.detail}`);
|
|
989
|
+
const approved = await finalConfirmation(stdin, stdout, [
|
|
990
|
+
`Installation root: ${root}`,
|
|
991
|
+
`Runtime image version: ${rawExisting?.version ?? "not installed"} -> ${runtimeVersion}`,
|
|
992
|
+
`Profile: ${rawExisting?.profile ?? "not configured"} -> ${resolveProfileChoice(parsed.requestedProfile).display}`,
|
|
993
|
+
`Access mode: ${rawExisting?.accessMode ?? "not configured"} -> ${resolveInstallAccessMode(parsed.requestedAccessMode, rawExisting?.accessMode ?? "isolated")}`,
|
|
994
|
+
...repairLines,
|
|
995
|
+
path === "preserve-recreate"
|
|
996
|
+
? "Runtime: preserve/recreate (containers are recreated without touching data volumes)."
|
|
997
|
+
: "Runtime: standard staged upgrade; previous runtime is restored automatically on failure.",
|
|
998
|
+
"Preserved: data, workspaces, credentials, secrets, and persistent Docker volumes."
|
|
999
|
+
]);
|
|
1000
|
+
if (!approved) {
|
|
1001
|
+
return { exit: 0 };
|
|
1002
|
+
}
|
|
1003
|
+
await applyApprovedConfigRepairs(root, rawExisting);
|
|
1004
|
+
if (platform === "win32") {
|
|
1005
|
+
await offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, {
|
|
1006
|
+
profile: parsed.requestedProfile,
|
|
1007
|
+
accessMode: parsed.requestedAccessMode,
|
|
1008
|
+
companionsEnabled: parsed.companionsEnabled,
|
|
1009
|
+
noOpen: parsed.noOpen
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
requestedProfile: parsed.requestedProfile,
|
|
1014
|
+
requestedAccessMode: parsed.requestedAccessMode,
|
|
1015
|
+
noOpen: parsed.noOpen,
|
|
1016
|
+
companionsEnabled: parsed.companionsEnabled,
|
|
1017
|
+
telemetryEnabled: false
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function hasRequestedConfigChange(rawExisting, parsed) {
|
|
1022
|
+
if (!rawExisting) {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
if (
|
|
1026
|
+
parsed.requestedAccessMode !== undefined &&
|
|
1027
|
+
rawExisting.accessMode !== parsed.requestedAccessMode
|
|
1028
|
+
) {
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
if (parsed.companionsEnabled && rawExisting.companionsEnabled !== true) {
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
if (parsed.requestedProfile !== "auto" && rawExisting.profile !== parsed.requestedProfile) {
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function applyApprovedConfigRepairs(root, rawExisting) {
|
|
1041
|
+
if (!rawExisting) {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
const { actions } = planConfigRepairs(rawExisting);
|
|
1045
|
+
if (actions.length === 0) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const repaired = applyConfigRepairs(rawExisting);
|
|
1049
|
+
await saveConfig(root, repaired);
|
|
1050
|
+
return repaired;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function detectDockerAvailable({ execute }) {
|
|
1054
|
+
try {
|
|
1055
|
+
return await execute({ command: "docker", args: ["--version"] }, { stdin: null, stdout: null, stderr: null });
|
|
1056
|
+
} catch {
|
|
1057
|
+
return 127;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function offerUnattendedContinuation(root, runtimeVersion, stdin, stdout, actions) {
|
|
1062
|
+
const answer = await promptYesNo(
|
|
1063
|
+
stdin,
|
|
1064
|
+
stdout,
|
|
1065
|
+
"Allow an unattended continuation for this same version on the next Windows RunOnce run (expires in 24h)?",
|
|
1066
|
+
{ defaultYes: false }
|
|
1067
|
+
);
|
|
1068
|
+
if (answer) {
|
|
1069
|
+
await writeRunOnceContinuation(root, { runtimeVersion, actions });
|
|
1070
|
+
stdout.write("Unattended continuation stored; it is consumed once and expires in 24 hours.\n");
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
async function repairRuntime({ root, config, platform, stdin, stdout, stderr, execute }) {
|
|
1075
|
+
const pullCode = await withHeadlessDockerConfig(
|
|
1076
|
+
platform,
|
|
1077
|
+
composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1078
|
+
(pullSpec) => execute(pullSpec, { stdin, stdout, stderr })
|
|
1079
|
+
);
|
|
1080
|
+
if (pullCode !== 0) {
|
|
1081
|
+
return pullCode;
|
|
1082
|
+
}
|
|
1083
|
+
const upCode = await execute(
|
|
1084
|
+
composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1085
|
+
{ stdin, stdout, stderr }
|
|
1086
|
+
);
|
|
1087
|
+
if (upCode !== 0) {
|
|
1088
|
+
return upCode;
|
|
1089
|
+
}
|
|
1090
|
+
stdout.write(`SpaceApp ${config.version} runtime repaired.\n`);
|
|
1091
|
+
return 0;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function performUpdate({
|
|
1095
|
+
root,
|
|
1096
|
+
config,
|
|
1097
|
+
targetVersion,
|
|
1098
|
+
platform,
|
|
1099
|
+
stdin,
|
|
1100
|
+
stdout,
|
|
1101
|
+
stderr,
|
|
1102
|
+
execute,
|
|
1103
|
+
preserveRecreate
|
|
1104
|
+
}) {
|
|
1105
|
+
const updated = targetVersion === config.version
|
|
1106
|
+
? config
|
|
1107
|
+
: {
|
|
1108
|
+
...config,
|
|
1109
|
+
version: targetVersion,
|
|
1110
|
+
previousVersion: config.version
|
|
1111
|
+
};
|
|
1112
|
+
const checkpoint = await createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform });
|
|
1113
|
+
try {
|
|
1114
|
+
await writeRuntimeFiles(root, updated);
|
|
1115
|
+
if (preserveRecreate) {
|
|
1116
|
+
const downCode = await execute(
|
|
1117
|
+
composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1118
|
+
{ stdin, stdout, stderr }
|
|
1119
|
+
);
|
|
1120
|
+
if (downCode !== 0) {
|
|
1121
|
+
throw new Error(`Preserve/recreate stop failed with Docker exit ${downCode}.`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const pullCode = await withHeadlessDockerConfig(
|
|
1125
|
+
platform,
|
|
1126
|
+
composeCommand("pull", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }),
|
|
1127
|
+
(pullSpec) => execute(pullSpec, { stdin, stdout, stderr })
|
|
1128
|
+
);
|
|
1129
|
+
if (pullCode !== 0) {
|
|
1130
|
+
throw new Error(`Image pull failed with Docker exit ${pullCode}.`);
|
|
1131
|
+
}
|
|
1132
|
+
const upCode = await execute(
|
|
1133
|
+
composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }),
|
|
1134
|
+
{ stdin, stdout, stderr }
|
|
1135
|
+
);
|
|
1136
|
+
if (upCode !== 0) {
|
|
1137
|
+
throw new Error(`Runtime start failed with Docker exit ${upCode}.`);
|
|
1138
|
+
}
|
|
1139
|
+
await saveConfig(root, updated);
|
|
1140
|
+
await markCheckpointVerified(checkpoint);
|
|
1141
|
+
stdout.write(`Updated to SpaceApp ${targetVersion}.\n`);
|
|
1142
|
+
return 0;
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
stderr.write(`SpaceApp update failed: ${error?.message || String(error)}\n`);
|
|
1145
|
+
const restored = await restoreCheckpoint(root, checkpoint, { stdin, stdout, stderr, execute, config });
|
|
1146
|
+
if (!restored) {
|
|
1147
|
+
stderr.write(
|
|
1148
|
+
`Automatic restore failed. The checkpoint remains available at ${checkpoint.path} for manual recovery.\n`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return 1;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const CHECKPOINT_ID_PATTERN = /^spaceapp-checkpoint-\d{8}T\d{6}Z$/;
|
|
1156
|
+
const CHECKPOINT_KEEP_COUNT = 2;
|
|
1157
|
+
|
|
1158
|
+
function checkpointId() {
|
|
1159
|
+
return `spaceapp-checkpoint-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z")}`;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function manifestEntry(path, logicalPath) {
|
|
1163
|
+
const content = await readFile(path);
|
|
1164
|
+
return {
|
|
1165
|
+
path: logicalPath,
|
|
1166
|
+
bytes: content.length,
|
|
1167
|
+
sha256: createHash("sha256").update(content).digest("hex")
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function cpDirectory(source, destination) {
|
|
1172
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
1173
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
1174
|
+
const entrySource = join(source, entry.name);
|
|
1175
|
+
const entryDestination = join(destination, entry.name);
|
|
1176
|
+
if (entry.isDirectory()) {
|
|
1177
|
+
await cpDirectory(entrySource, entryDestination);
|
|
1178
|
+
} else {
|
|
1179
|
+
await copyFile(entrySource, entryDestination);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function collectFiles(directory, logicalDirectory) {
|
|
1185
|
+
const files = [];
|
|
1186
|
+
for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
|
|
1187
|
+
if (entry.isDirectory()) {
|
|
1188
|
+
files.push(...await collectFiles(join(directory, entry.name), `${logicalDirectory}/${entry.name}`));
|
|
1189
|
+
} else {
|
|
1190
|
+
files.push(await manifestEntry(join(directory, entry.name), `${logicalDirectory}/${entry.name}`));
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return files;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, platform }) {
|
|
1197
|
+
const id = checkpointId();
|
|
1198
|
+
const path = join(root, "checkpoints", id);
|
|
1199
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
1200
|
+
const manifest = {
|
|
1201
|
+
id,
|
|
1202
|
+
createdAt: new Date().toISOString(),
|
|
1203
|
+
version: config.version,
|
|
1204
|
+
files: []
|
|
1205
|
+
};
|
|
1206
|
+
const fileNames = [
|
|
1207
|
+
"config.json",
|
|
1208
|
+
"runtime.env",
|
|
1209
|
+
"compose.yml",
|
|
1210
|
+
"compose.workspaces.yml",
|
|
1211
|
+
"compose.host-access.yml"
|
|
1212
|
+
];
|
|
1213
|
+
for (const fileName of fileNames) {
|
|
1214
|
+
await copyFile(join(root, fileName), join(path, fileName));
|
|
1215
|
+
manifest.files.push(await manifestEntry(join(path, fileName), fileName));
|
|
1216
|
+
}
|
|
1217
|
+
await cpDirectory(join(root, "secrets"), join(path, "secrets"));
|
|
1218
|
+
for (const file of await collectFiles(join(path, "secrets"), "secrets")) {
|
|
1219
|
+
manifest.files.push(file);
|
|
1220
|
+
}
|
|
1221
|
+
stdout.write("Creating checkpoint (configuration, secrets, and database dump)...\n");
|
|
1222
|
+
const dumpPath = join(path, "postgres.dump");
|
|
1223
|
+
const dumpCode = await execute(
|
|
1224
|
+
composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1225
|
+
{ stdin: null, stdout: createWriteStream(dumpPath), stderr }
|
|
1226
|
+
);
|
|
1227
|
+
if (dumpCode !== 0) {
|
|
1228
|
+
await rm(path, { recursive: true, force: true });
|
|
1229
|
+
throw new Error(`Checkpoint database dump failed with Docker exit ${dumpCode}. No changes were made.`);
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
await stat(dumpPath);
|
|
1233
|
+
} catch {
|
|
1234
|
+
await writeFile(dumpPath, "");
|
|
1235
|
+
}
|
|
1236
|
+
manifest.files.push(await manifestEntry(dumpPath, "postgres.dump"));
|
|
1237
|
+
await writeFile(join(path, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
1238
|
+
const verified = await verifyCheckpoint({ path, manifest });
|
|
1239
|
+
if (!verified) {
|
|
1240
|
+
throw new Error(`Checkpoint verification failed. The checkpoint remains at ${path}. No changes were made.`);
|
|
1241
|
+
}
|
|
1242
|
+
return { id, path, manifest };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function verifyCheckpoint(checkpoint) {
|
|
1246
|
+
for (const file of checkpoint.manifest.files ?? []) {
|
|
1247
|
+
const actual = await manifestEntry(join(checkpoint.path, file.path), file.path).catch(() => null);
|
|
1248
|
+
if (!actual || actual.bytes !== file.bytes || actual.sha256 !== file.sha256) {
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return true;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async function markCheckpointVerified(checkpoint) {
|
|
1256
|
+
await writeFile(
|
|
1257
|
+
join(checkpoint.path, "verified.json"),
|
|
1258
|
+
`${JSON.stringify({ verifiedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
1259
|
+
{ mode: 0o600 }
|
|
1260
|
+
);
|
|
1261
|
+
await pruneCheckpoints(checkpoint.path);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function pruneCheckpoints(currentPath) {
|
|
1265
|
+
const parent = join(currentPath, "..");
|
|
1266
|
+
const entries = (await readdir(parent, { withFileTypes: true }))
|
|
1267
|
+
.filter((entry) => entry.isDirectory() && CHECKPOINT_ID_PATTERN.test(entry.name))
|
|
1268
|
+
.map((entry) => join(parent, entry.name))
|
|
1269
|
+
.sort();
|
|
1270
|
+
while (entries.length > CHECKPOINT_KEEP_COUNT) {
|
|
1271
|
+
await rm(entries.shift(), { recursive: true, force: true });
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function restoreCheckpoint(root, checkpoint, { stdin, stdout, stderr, execute, config }) {
|
|
1276
|
+
if (!checkpoint) {
|
|
1277
|
+
stderr.write("No checkpoint was available for restore.\n");
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
const verified = await verifyCheckpoint(checkpoint);
|
|
1281
|
+
if (!verified) {
|
|
1282
|
+
stderr.write(`Checkpoint ${checkpoint.id} failed verification; it remains at ${checkpoint.path} for manual recovery.\n`);
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
stdout.write(`Restoring checkpoint ${checkpoint.id}...\n`);
|
|
1286
|
+
await execute(
|
|
1287
|
+
composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
1288
|
+
{ stdin: null, stdout: null, stderr: null }
|
|
1289
|
+
).catch(() => {});
|
|
1290
|
+
const fileNames = ["config.json", "runtime.env", "compose.yml", "compose.workspaces.yml", "compose.host-access.yml"];
|
|
1291
|
+
for (const fileName of fileNames) {
|
|
1292
|
+
await copyFile(join(checkpoint.path, fileName), join(root, fileName));
|
|
1293
|
+
}
|
|
1294
|
+
const restored = await loadConfig(root);
|
|
1295
|
+
const dumpPath = join(checkpoint.path, "postgres.dump");
|
|
1296
|
+
const dbCode = await execute(
|
|
1297
|
+
composeCommand("checkpointRestore", root, { profile: restored.profile, companionsEnabled: restored.companionsEnabled }),
|
|
1298
|
+
{ stdin: createReadStream(dumpPath), stdout, stderr }
|
|
1299
|
+
);
|
|
1300
|
+
if (dbCode !== 0) {
|
|
1301
|
+
stderr.write(`Checkpoint database restore failed with Docker exit ${dbCode}. Checkpoint remains at ${checkpoint.path}.\n`);
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
const upCode = await execute(
|
|
1305
|
+
composeCommand("up", root, { profile: restored.profile, companionsEnabled: restored.companionsEnabled }),
|
|
1306
|
+
{ stdin, stdout, stderr }
|
|
1307
|
+
);
|
|
1308
|
+
return upCode === 0;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function requireChangeApproval(stdin, stdout, stderr, lines) {
|
|
1312
|
+
if (!interactiveAvailable(stdin)) {
|
|
1313
|
+
stderr.write("This change requires an interactive terminal (TTY). No changes were made.\n");
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
return finalConfirmation(stdin, stdout, lines);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
626
1319
|
async function reportInstallDiagnostics({
|
|
627
1320
|
root,
|
|
628
1321
|
stateRoot,
|
|
@@ -905,32 +1598,90 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
|
|
|
905
1598
|
});
|
|
906
1599
|
}
|
|
907
1600
|
|
|
908
|
-
async function updateCommand(args, { root, config, version, stdin, stdout, stderr, execute }) {
|
|
1601
|
+
async function updateCommand(args, { root, config, version, platform, stdin, stdout, stderr, execute }) {
|
|
909
1602
|
if (args.length > 1) {
|
|
910
1603
|
throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
|
|
911
1604
|
}
|
|
912
1605
|
const targetVersion = args[0] || version;
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1606
|
+
const path = upgradePath(config.version, targetVersion);
|
|
1607
|
+
const interactive = interactiveAvailable(stdin);
|
|
1608
|
+
const continuation = await readRunOnceContinuation(root, { platform, runtimeVersion: targetVersion });
|
|
1609
|
+
if (!interactive && !continuation) {
|
|
1610
|
+
stderr.write(
|
|
1611
|
+
"SpaceApp update changes require an interactive terminal (TTY). " +
|
|
1612
|
+
"Re-run from a terminal, or continue an approved Windows RunOnce plan.\n"
|
|
1613
|
+
);
|
|
1614
|
+
return 1;
|
|
1615
|
+
}
|
|
1616
|
+
if (continuation) {
|
|
1617
|
+
await consumeRunOnceContinuation(root);
|
|
1618
|
+
stdout.write("Continuing the approved unattended SpaceApp update plan.\n");
|
|
1619
|
+
}
|
|
1620
|
+
if (path === "same") {
|
|
1621
|
+
if (!continuation) {
|
|
1622
|
+
const choice = await promptChoice(stdin, stdout, `SpaceApp ${config.version} is already installed. What would you like to do?`, [
|
|
1623
|
+
{ label: "Run doctor diagnostics (read-only)", value: "doctor" },
|
|
1624
|
+
{ label: "Repair the runtime (recreate containers from the current configuration)", value: "repair" },
|
|
1625
|
+
{ label: "Cancel", value: "cancel" }
|
|
1626
|
+
]);
|
|
1627
|
+
if (choice === "cancel") {
|
|
1628
|
+
stdout.write("Cancelled. No changes were made.\n");
|
|
1629
|
+
return 0;
|
|
1630
|
+
}
|
|
1631
|
+
if (choice === "doctor") {
|
|
1632
|
+
return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources: inspectSystemResources });
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return repairRuntime({ root, config, platform, stdin, stdout, stderr, execute });
|
|
925
1636
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1637
|
+
if (path === "downgrade") {
|
|
1638
|
+
if (!continuation) {
|
|
1639
|
+
stderr.write(
|
|
1640
|
+
`WARNING: target ${targetVersion} is OLDER than the installed ${config.version}.\n` +
|
|
1641
|
+
"A downgrade can lose data created by the newer version.\n"
|
|
1642
|
+
);
|
|
1643
|
+
const typed = await readSecret(stdin, stdout, "Type DOWNGRADE to proceed with the controlled rollback: ", { mask: false });
|
|
1644
|
+
if (typed !== "DOWNGRADE") {
|
|
1645
|
+
stdout.write("Cancelled. No changes were made.\n");
|
|
1646
|
+
return 0;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
} else if (path === "unsupported") {
|
|
1650
|
+
stderr.write(
|
|
1651
|
+
`Installed version ${config.version} is older than the minimum supported upgrade source ` +
|
|
1652
|
+
`${SPACEAPP_UPGRADE_POLICY.minSupportedSourceVersion}. Make a backup, then reinstall from scratch.\n`
|
|
1653
|
+
);
|
|
1654
|
+
return 1;
|
|
1655
|
+
} else if (path === "unknown") {
|
|
1656
|
+
stderr.write(
|
|
1657
|
+
`Installed version ${config.version} cannot be compared with target ${targetVersion}. Update cancelled.\n`
|
|
1658
|
+
);
|
|
1659
|
+
return 1;
|
|
1660
|
+
}
|
|
1661
|
+
if (!continuation) {
|
|
1662
|
+
const approved = await finalConfirmation(stdin, stdout, [
|
|
1663
|
+
`Runtime image version: ${config.version} -> ${targetVersion}`,
|
|
1664
|
+
"Checkpoint: configuration, secrets, and a database dump are saved before the change and restored automatically on failure.",
|
|
1665
|
+
"Downtime: containers restart during the cutover.",
|
|
1666
|
+
SPACEAPP_UPGRADE_POLICY.rollbackCapable
|
|
1667
|
+
? "Rollback: the previous runtime is restored automatically on failure; the previous version stays recorded for manual rollback."
|
|
1668
|
+
: "Rollback: not supported for this target version."
|
|
1669
|
+
]);
|
|
1670
|
+
if (!approved) {
|
|
1671
|
+
return 0;
|
|
1672
|
+
}
|
|
930
1673
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1674
|
+
return performUpdate({
|
|
1675
|
+
root,
|
|
1676
|
+
config,
|
|
1677
|
+
targetVersion,
|
|
1678
|
+
platform,
|
|
1679
|
+
stdin,
|
|
1680
|
+
stdout,
|
|
1681
|
+
stderr,
|
|
1682
|
+
execute,
|
|
1683
|
+
preserveRecreate: path === "preserve-recreate"
|
|
1684
|
+
});
|
|
934
1685
|
}
|
|
935
1686
|
|
|
936
1687
|
async function doctor({
|
|
@@ -1307,5 +2058,11 @@ Usage: ${UNIVERSAL_COMMAND} <command>
|
|
|
1307
2058
|
owner reset-password Read the new password from masked stdin
|
|
1308
2059
|
owner rotate-setup-token Replace an expired unclaimed setup token
|
|
1309
2060
|
uninstall [--purge-data] Remove containers; keep data by default
|
|
2061
|
+
|
|
2062
|
+
Interactive setup wizard: install, update, init, rollback, and uninstall ask
|
|
2063
|
+
questions and require a final confirmation before any change. Command flags
|
|
2064
|
+
pre-select answers but never skip the confirmation. Without a TTY, changes
|
|
2065
|
+
are refused; the only exception is an approved Windows RunOnce continuation
|
|
2066
|
+
(bound to the user, installation root, and target version; expires in 24h).
|
|
1310
2067
|
`;
|
|
1311
2068
|
}
|
package/src/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "./string-utils.mjs";
|
|
20
20
|
import { UNIVERSAL_COMMAND } from "./package-info.mjs";
|
|
21
21
|
|
|
22
|
-
const CONFIG_SCHEMA_VERSION =
|
|
22
|
+
const CONFIG_SCHEMA_VERSION = 4;
|
|
23
23
|
const MIN_INSTALL_CPU_COUNT = 4;
|
|
24
24
|
const MIN_INSTALL_MEMORY_BYTES = 7 * 1024 ** 3;
|
|
25
25
|
const MIN_INSTALL_MEMORY_LABEL = "7 GiB usable (8 GB-class system)";
|
|
@@ -75,6 +75,13 @@ const CONFIG_KEYS = new Set([
|
|
|
75
75
|
"companionsEnabled"
|
|
76
76
|
]);
|
|
77
77
|
|
|
78
|
+
export const SPACEAPP_UPGRADE_POLICY = Object.freeze({
|
|
79
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
80
|
+
minSupportedSourceVersion: "0.1.10",
|
|
81
|
+
preserveRecreateSourceVersions: Object.freeze([]),
|
|
82
|
+
rollbackCapable: true
|
|
83
|
+
});
|
|
84
|
+
|
|
78
85
|
export function resolveSpaceAppHome({
|
|
79
86
|
env = process.env,
|
|
80
87
|
platform = process.platform,
|
|
@@ -115,10 +122,64 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated
|
|
|
115
122
|
throw new Error("Install access mode must be isolated or host-root.");
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
export function versionTuple(version) {
|
|
126
|
+
if (typeof version !== "string") {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version);
|
|
130
|
+
if (!match) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function compareVersions(a, b) {
|
|
137
|
+
const aTuple = versionTuple(a);
|
|
138
|
+
const bTuple = versionTuple(b);
|
|
139
|
+
if (!aTuple || !bTuple) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
for (let index = 0; index < 3; index += 1) {
|
|
143
|
+
if (aTuple[index] !== bTuple[index]) {
|
|
144
|
+
return aTuple[index] < bTuple[index] ? -1 : 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function upgradePath(sourceVersion, targetVersion) {
|
|
151
|
+
if (sourceVersion === null || sourceVersion === undefined) {
|
|
152
|
+
return "fresh";
|
|
153
|
+
}
|
|
154
|
+
if (sourceVersion === targetVersion) {
|
|
155
|
+
return "same";
|
|
156
|
+
}
|
|
157
|
+
const comparison = compareVersions(sourceVersion, targetVersion);
|
|
158
|
+
if (comparison === null) {
|
|
159
|
+
return "unknown";
|
|
160
|
+
}
|
|
161
|
+
if (comparison > 0) {
|
|
162
|
+
return "downgrade";
|
|
163
|
+
}
|
|
164
|
+
const minimum = compareVersions(sourceVersion, SPACEAPP_UPGRADE_POLICY.minSupportedSourceVersion);
|
|
165
|
+
if (minimum === null || minimum < 0) {
|
|
166
|
+
return "unsupported";
|
|
167
|
+
}
|
|
168
|
+
if (SPACEAPP_UPGRADE_POLICY.preserveRecreateSourceVersions.includes(sourceVersion)) {
|
|
169
|
+
return "preserve-recreate";
|
|
170
|
+
}
|
|
171
|
+
return "update";
|
|
172
|
+
}
|
|
173
|
+
|
|
118
174
|
export async function inspectSystemResources(root) {
|
|
119
175
|
validateHome(root);
|
|
120
|
-
|
|
121
|
-
|
|
176
|
+
let target = root;
|
|
177
|
+
try {
|
|
178
|
+
await stat(target);
|
|
179
|
+
} catch {
|
|
180
|
+
target = dirname(root);
|
|
181
|
+
}
|
|
182
|
+
const fileSystem = await statfs(target);
|
|
122
183
|
return {
|
|
123
184
|
cpuCount: availableParallelism(),
|
|
124
185
|
totalMemoryBytes: totalmem(),
|
|
@@ -245,6 +306,13 @@ function migrateConfig(config) {
|
|
|
245
306
|
companionsEnabled: false
|
|
246
307
|
};
|
|
247
308
|
}
|
|
309
|
+
if (config?.schemaVersion === 3) {
|
|
310
|
+
return {
|
|
311
|
+
...config,
|
|
312
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
313
|
+
...(config.companionsEnabled === undefined ? { companionsEnabled: false } : {})
|
|
314
|
+
};
|
|
315
|
+
}
|
|
248
316
|
if (config?.schemaVersion !== 1) {
|
|
249
317
|
return config;
|
|
250
318
|
}
|
|
@@ -261,6 +329,60 @@ function migrateConfig(config) {
|
|
|
261
329
|
};
|
|
262
330
|
}
|
|
263
331
|
|
|
332
|
+
export function planConfigRepairs(rawConfig) {
|
|
333
|
+
const actions = [];
|
|
334
|
+
if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
|
|
335
|
+
return { actions };
|
|
336
|
+
}
|
|
337
|
+
const schema = rawConfig.schemaVersion;
|
|
338
|
+
if (schema === 1 || schema === 2) {
|
|
339
|
+
actions.push({
|
|
340
|
+
type: "migrate-legacy-schema",
|
|
341
|
+
from: schema,
|
|
342
|
+
to: CONFIG_SCHEMA_VERSION,
|
|
343
|
+
detail: `Config schema ${schema} will be migrated to schema ${CONFIG_SCHEMA_VERSION}.`
|
|
344
|
+
});
|
|
345
|
+
return { actions };
|
|
346
|
+
}
|
|
347
|
+
if (schema === 3) {
|
|
348
|
+
if (rawConfig.companionsEnabled === undefined) {
|
|
349
|
+
actions.push({
|
|
350
|
+
type: "default-companions",
|
|
351
|
+
detail: "companionsEnabled is missing and will default to false."
|
|
352
|
+
});
|
|
353
|
+
} else if (
|
|
354
|
+
typeof rawConfig.companionsEnabled === "string" &&
|
|
355
|
+
(rawConfig.companionsEnabled === "true" || rawConfig.companionsEnabled === "false")
|
|
356
|
+
) {
|
|
357
|
+
actions.push({
|
|
358
|
+
type: "convert-companions-string",
|
|
359
|
+
value: rawConfig.companionsEnabled,
|
|
360
|
+
detail: `companionsEnabled is the string "${rawConfig.companionsEnabled}" and will be converted to the boolean ${rawConfig.companionsEnabled === "true"}.`
|
|
361
|
+
});
|
|
362
|
+
} else if (typeof rawConfig.companionsEnabled !== "boolean") {
|
|
363
|
+
actions.push({
|
|
364
|
+
type: "reject-companions",
|
|
365
|
+
detail: "companionsEnabled has an unsupported value and cannot be repaired automatically."
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { actions };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function applyConfigRepairs(rawConfig) {
|
|
373
|
+
const { actions } = planConfigRepairs(rawConfig);
|
|
374
|
+
const rejected = actions.find((action) => action.type === "reject-companions");
|
|
375
|
+
if (rejected) {
|
|
376
|
+
throw new Error(rejected.detail);
|
|
377
|
+
}
|
|
378
|
+
let repaired = migrateConfig(structuredClone(rawConfig));
|
|
379
|
+
const convert = actions.find((action) => action.type === "convert-companions-string");
|
|
380
|
+
if (convert) {
|
|
381
|
+
repaired = { ...repaired, companionsEnabled: convert.value === "true" };
|
|
382
|
+
}
|
|
383
|
+
return validateConfig(repaired);
|
|
384
|
+
}
|
|
385
|
+
|
|
264
386
|
export async function addWorkspace(config, hostPath, { readOnly = false } = {}) {
|
|
265
387
|
validateConfig(config);
|
|
266
388
|
if (!isAbsolute(hostPath)) {
|
|
@@ -484,7 +606,10 @@ export function composeCommand(action, root, options = {}) {
|
|
|
484
606
|
"--stdin"
|
|
485
607
|
],
|
|
486
608
|
removeBrowser: ["rm", "--stop", "--force", "spaceapp-browser"],
|
|
487
|
-
purge: ["down", "--volumes", "--remove-orphans"]
|
|
609
|
+
purge: ["down", "--volumes", "--remove-orphans"],
|
|
610
|
+
repair: ["up", "-d", "--remove-orphans", "--force-recreate"],
|
|
611
|
+
checkpointDump: ["exec", "-T", "postgres", "pg_dump", "-c", "--if-exists", "-U", "spaceapp", "-d", "spaceapp"],
|
|
612
|
+
checkpointRestore: ["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "spaceapp", "-d", "spaceapp"]
|
|
488
613
|
};
|
|
489
614
|
let selected = actions[action];
|
|
490
615
|
if (action === "restore") {
|