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.
Files changed (3) hide show
  1. package/package.json +3 -3
  2. package/src/cli.mjs +752 -28
  3. 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.24",
4
- "spaceappRuntimeVersion": "0.1.24",
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": "1d6601cc265e2ab16c4e029d898eb7736998f81e"
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 { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
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 { requestedProfile, requestedAccessMode, noOpen, companionsEnabled } = parseInstallArgs(args);
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 updated = targetVersion === config.version
943
- ? config
944
- : {
945
- ...config,
946
- version: targetVersion,
947
- previousVersion: config.version
948
- };
949
- await writeRuntimeFiles(root, updated);
950
- const pullCode = await withHeadlessDockerConfig(
951
- platform,
952
- composeCommand("pull", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }),
953
- (pullSpec) => execute(pullSpec, { stdin, stdout, stderr })
954
- );
955
- if (pullCode !== 0) {
956
- await writeRuntimeFiles(root, config);
957
- return pullCode;
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
- const upCode = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr });
960
- if (upCode !== 0) {
961
- await writeRuntimeFiles(root, config);
962
- return upCode;
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
- await saveConfig(root, updated);
965
- stdout.write(`Updated to SpaceApp ${targetVersion}.\n`);
966
- return 0;
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 = 3;
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
- await mkdir(root, { recursive: true, mode: 0o700 });
121
- const fileSystem = await statfs(root);
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") {