run-spaceapp 0.1.24 → 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/package.json +3 -3
- package/src/cli.mjs +752 -28
- package/src/index.mjs +129 -4
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
|
|
@@ -88,6 +102,139 @@ export async function withHeadlessDockerConfig(platform, spec, run) {
|
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
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
|
+
|
|
91
238
|
export async function run(argv, {
|
|
92
239
|
env = process.env,
|
|
93
240
|
platform = process.platform,
|
|
@@ -143,6 +290,13 @@ export async function run(argv, {
|
|
|
143
290
|
}
|
|
144
291
|
if (command === "init") {
|
|
145
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
|
+
}
|
|
146
300
|
const result = await initializeInstallation(root, { version: runtimeVersion });
|
|
147
301
|
stdout.write(`SpaceApp initialized at ${root}\n`);
|
|
148
302
|
if (result.setupToken) {
|
|
@@ -212,6 +366,13 @@ export async function run(argv, {
|
|
|
212
366
|
if (!config.previousVersion) {
|
|
213
367
|
throw new Error("No previous SpaceApp version is recorded.");
|
|
214
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
|
+
}
|
|
215
376
|
const rollback = {
|
|
216
377
|
...config,
|
|
217
378
|
version: config.previousVersion,
|
|
@@ -265,6 +426,14 @@ export async function run(argv, {
|
|
|
265
426
|
}
|
|
266
427
|
if (command === "uninstall") {
|
|
267
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
|
+
}
|
|
268
437
|
stdout.write("Stopping and removing SpaceApp containers and network...\n");
|
|
269
438
|
const code = await runtimeExecute(
|
|
270
439
|
composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }),
|
|
@@ -328,7 +497,27 @@ async function installCommand(args, {
|
|
|
328
497
|
sleep,
|
|
329
498
|
persistSetupToken
|
|
330
499
|
}) {
|
|
331
|
-
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;
|
|
332
521
|
const existingConfig = await loadExistingInstallation(root);
|
|
333
522
|
const accessMode = resolveInstallAccessMode(
|
|
334
523
|
requestedAccessMode,
|
|
@@ -356,6 +545,9 @@ async function installCommand(args, {
|
|
|
356
545
|
accessMode,
|
|
357
546
|
...(companionsEnabled ? { companionsEnabled } : {})
|
|
358
547
|
});
|
|
548
|
+
if (telemetryEnabled && !existingConfig) {
|
|
549
|
+
result.config = { ...result.config, telemetry: true };
|
|
550
|
+
}
|
|
359
551
|
|
|
360
552
|
stdout.write(`Launcher version: ${launcherVersion}\n`);
|
|
361
553
|
stdout.write(
|
|
@@ -652,6 +844,478 @@ async function installCommand(args, {
|
|
|
652
844
|
}
|
|
653
845
|
}
|
|
654
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
|
+
|
|
655
1319
|
async function reportInstallDiagnostics({
|
|
656
1320
|
root,
|
|
657
1321
|
stateRoot,
|
|
@@ -939,31 +1603,85 @@ async function updateCommand(args, { root, config, version, platform, stdin, std
|
|
|
939
1603
|
throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
|
|
940
1604
|
}
|
|
941
1605
|
const targetVersion = args[0] || version;
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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 });
|
|
958
1636
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
+
}
|
|
963
1673
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
+
});
|
|
967
1685
|
}
|
|
968
1686
|
|
|
969
1687
|
async function doctor({
|
|
@@ -1340,5 +2058,11 @@ Usage: ${UNIVERSAL_COMMAND} <command>
|
|
|
1340
2058
|
owner reset-password Read the new password from masked stdin
|
|
1341
2059
|
owner rotate-setup-token Replace an expired unclaimed setup token
|
|
1342
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).
|
|
1343
2067
|
`;
|
|
1344
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") {
|