run-spaceapp 1.0.2 → 1.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ import { createWriteStream, readFileSync } from "node:fs";
2
+
3
+ export class PromptController {
4
+ constructor({
5
+ stdin = process.stdin,
6
+ stdout = process.stdout,
7
+ stderr = process.stderr,
8
+ logFile = null,
9
+ nonInteractive = false,
10
+ answersFile = null,
11
+ answers = null,
12
+ planMode = false,
13
+ jsonMode = false
14
+ } = {}) {
15
+ this.stdin = stdin;
16
+ this.stdout = stdout;
17
+ this.stderr = stderr;
18
+ this.nonInteractive = nonInteractive;
19
+ this.planMode = planMode;
20
+ this.jsonMode = jsonMode;
21
+ this.logStream = logFile ? createWriteStream(logFile, { flags: "a" }) : null;
22
+ this.answers = answers ? { ...answers } : {};
23
+ this._sawCr = false;
24
+
25
+ if (answersFile) {
26
+ try {
27
+ const content = answersFile.trim().startsWith("{") ? answersFile : readFileSync(answersFile, "utf8");
28
+ const parsed = JSON.parse(content);
29
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Answers must be a JSON object");
30
+ this.answers = { ...this.answers, ...parsed };
31
+ } catch (err) {
32
+ throw new Error(`Failed to parse answers file ${answersFile}: ${err.message}`);
33
+ }
34
+ }
35
+
36
+ if (!stdin._spaceappBuffer) {
37
+ stdin._spaceappBuffer = "";
38
+ }
39
+ }
40
+
41
+ log(message, { secret = false } = {}) {
42
+ if (this.logStream && !secret) {
43
+ this.logStream.write(`[${new Date().toISOString()}] ${message}\n`);
44
+ }
45
+ }
46
+
47
+ write(text, { secret = false } = {}) {
48
+ if (!this.jsonMode) {
49
+ this.stdout.write(text);
50
+ }
51
+ this.log(text.replace(/\r?\n$/, ""), { secret });
52
+ }
53
+
54
+ writeError(text) {
55
+ this.stderr.write(text);
56
+ this.log(`ERROR: ${text.replace(/\r?\n$/, "")}`);
57
+ }
58
+
59
+ isInteractive() {
60
+ return Boolean(this.stdin?.isTTY && typeof this.stdin?.setRawMode === "function");
61
+ }
62
+
63
+ async readLine(promptText, { mask = false, answerKey = null, defaultValue = "" } = {}) {
64
+ if (answerKey && this.answers[answerKey] !== undefined) {
65
+ const val = String(this.answers[answerKey]);
66
+ this.write(`${promptText}${mask ? "******" : val}\n`, { secret: mask });
67
+ return val;
68
+ }
69
+
70
+ if (this.nonInteractive) {
71
+ if (defaultValue !== undefined && defaultValue !== "") {
72
+ this.write(`${promptText}${defaultValue} (non-interactive default)\n`);
73
+ return defaultValue;
74
+ }
75
+ throw new Error(`Non-interactive execution refused prompt without answer: ${promptText.trim()}`);
76
+ }
77
+
78
+ this.write(promptText, { secret: false });
79
+
80
+ // Handle split CRLF from previous read: if previous ended with \r, strip leading \n
81
+ let buf = this.stdin._spaceappBuffer || "";
82
+ if (this._sawCr && buf.length > 0) {
83
+ if (buf.startsWith("\n")) {
84
+ buf = buf.slice(1);
85
+ this.stdin._spaceappBuffer = buf;
86
+ }
87
+ this._sawCr = false;
88
+ }
89
+
90
+ // 1. Check if unconsumed characters in buffer already contain a newline
91
+ const nlIdx = buf.search(/\r\n|\n|\r/);
92
+ if (nlIdx !== -1) {
93
+ const line = buf.slice(0, nlIdx);
94
+ if (buf[nlIdx] === "\r") {
95
+ if (buf.length > nlIdx + 1 && buf[nlIdx + 1] === "\n") {
96
+ this.stdin._spaceappBuffer = buf.slice(nlIdx + 2);
97
+ } else {
98
+ this.stdin._spaceappBuffer = buf.slice(nlIdx + 1);
99
+ this._sawCr = true;
100
+ }
101
+ } else {
102
+ this.stdin._spaceappBuffer = buf.slice(nlIdx + 1);
103
+ }
104
+ if (!mask) {
105
+ this.write(`${line}\n`);
106
+ } else {
107
+ this.write("\n", { secret: true });
108
+ }
109
+ return line;
110
+ }
111
+
112
+ // 2. Non-TTY / Piped stream (where setRawMode or isTTY is missing)
113
+ if (!this.stdin.isTTY || typeof this.stdin.setRawMode !== "function") {
114
+ return await new Promise((resolve, reject) => {
115
+ let current = this.stdin._spaceappBuffer || "";
116
+ this.stdin._spaceappBuffer = "";
117
+
118
+ const finish = (result) => {
119
+ cleanup();
120
+ this.write("\n", { secret: mask });
121
+ resolve(result);
122
+ };
123
+
124
+ const processBuffer = () => {
125
+ if (this._sawCr && current.length > 0) {
126
+ if (current.startsWith("\n")) {
127
+ current = current.slice(1);
128
+ }
129
+ this._sawCr = false;
130
+ }
131
+ const m = current.search(/\r\n|\n|\r/);
132
+ if (m !== -1) {
133
+ const res = current.slice(0, m);
134
+ if (current[m] === "\r") {
135
+ if (current.length > m + 1 && current[m + 1] === "\n") {
136
+ this.stdin._spaceappBuffer = current.slice(m + 2);
137
+ } else {
138
+ this.stdin._spaceappBuffer = current.slice(m + 1);
139
+ this._sawCr = true;
140
+ }
141
+ } else {
142
+ this.stdin._spaceappBuffer = current.slice(m + 1);
143
+ }
144
+ finish(res);
145
+ return true;
146
+ }
147
+ return false;
148
+ };
149
+
150
+ const onReadable = () => {
151
+ let chunk;
152
+ while ((chunk = this.stdin.read()) !== null) {
153
+ current += String(chunk);
154
+ if (processBuffer()) {
155
+ return;
156
+ }
157
+ }
158
+ };
159
+
160
+ const onEnd = () => finish(current);
161
+ const onClose = () => finish(current);
162
+ const onError = (err) => {
163
+ cleanup();
164
+ reject(err);
165
+ };
166
+
167
+ const cleanup = () => {
168
+ if (typeof this.stdin.off === "function") {
169
+ this.stdin.off("readable", onReadable);
170
+ this.stdin.off("end", onEnd);
171
+ this.stdin.off("close", onClose);
172
+ this.stdin.off("error", onError);
173
+ }
174
+ };
175
+
176
+ if (typeof this.stdin.read === "function" && typeof this.stdin.on === "function") {
177
+ this.stdin.on("readable", onReadable);
178
+ this.stdin.once("end", onEnd);
179
+ this.stdin.once("close", onClose);
180
+ this.stdin.once("error", onError);
181
+ onReadable();
182
+ } else if (typeof this.stdin[Symbol.asyncIterator] === "function") {
183
+ (async () => {
184
+ try {
185
+ const iterator = this.stdin[Symbol.asyncIterator]();
186
+ for (;;) {
187
+ const step = await iterator.next();
188
+ if (step.done) {
189
+ finish(current);
190
+ return;
191
+ }
192
+ current += String(step.value);
193
+ if (processBuffer()) {
194
+ return;
195
+ }
196
+ }
197
+ } catch {
198
+ finish(current);
199
+ }
200
+ })();
201
+ } else {
202
+ finish(current);
203
+ }
204
+ });
205
+ }
206
+
207
+ // 3. Interactive TTY stream (Real TTY or test mock implementing isTTY + setRawMode)
208
+ if (typeof this.stdin.setRawMode === "function") {
209
+ this.stdin.setRawMode(true);
210
+ }
211
+ if (typeof this.stdin.resume === "function") {
212
+ this.stdin.resume();
213
+ }
214
+ if (typeof this.stdin.setEncoding === "function") {
215
+ this.stdin.setEncoding("utf8");
216
+ }
217
+ let value = "";
218
+ try {
219
+ const iterator = this.stdin[Symbol.asyncIterator]();
220
+ for (;;) {
221
+ const step = await iterator.next();
222
+ if (step.done) break;
223
+ const chars = Array.from(String(step.value));
224
+ for (let i = 0; i < chars.length; i++) {
225
+ const character = chars[i];
226
+ if (this._sawCr && character === "\n") {
227
+ this._sawCr = false;
228
+ continue;
229
+ }
230
+ this._sawCr = false;
231
+ if (character === "\u0003") {
232
+ throw new Error("Input cancelled.");
233
+ }
234
+ if (character === "\r" || character === "\n") {
235
+ if (character === "\r") {
236
+ this._sawCr = true;
237
+ }
238
+ if (i + 1 < chars.length) {
239
+ let rest = chars.slice(i + 1).join("");
240
+ if (character === "\r" && rest.startsWith("\n")) {
241
+ rest = rest.slice(1);
242
+ this._sawCr = false;
243
+ }
244
+ this.stdin._spaceappBuffer = (this.stdin._spaceappBuffer || "") + rest;
245
+ }
246
+ this.write("\n", { secret: mask });
247
+ return value;
248
+ }
249
+ if (character === "\u007f" || character === "\u0008") {
250
+ if (value.length > 0) {
251
+ value = value.slice(0, -1);
252
+ this.stdout.write("\b \b");
253
+ }
254
+ continue;
255
+ }
256
+ value += character;
257
+ if (mask) {
258
+ this.stdout.write("*");
259
+ } else {
260
+ this.stdout.write(character);
261
+ }
262
+ }
263
+ }
264
+ this.write("\n", { secret: mask });
265
+ return value;
266
+ } finally {
267
+ if (typeof this.stdin.setRawMode === "function") {
268
+ this.stdin.setRawMode(false);
269
+ }
270
+ if (typeof this.stdin.pause === "function") {
271
+ this.stdin.pause();
272
+ }
273
+ }
274
+ }
275
+
276
+ async promptYesNo(question, { defaultYes = false, answerKey = null } = {}) {
277
+ for (;;) {
278
+ const raw = await this.readLine(
279
+ `${question} [${defaultYes ? "Y/n" : "y/N"}] `,
280
+ { mask: false, answerKey, defaultValue: defaultYes ? "y" : "n" }
281
+ );
282
+ const answer = raw.trim().toLowerCase();
283
+ if (answer === "") {
284
+ return defaultYes;
285
+ }
286
+ if (answer === "y" || answer === "yes" || answer === "true" || answer === "1") {
287
+ return true;
288
+ }
289
+ if (answer === "n" || answer === "no" || answer === "false" || answer === "0") {
290
+ return false;
291
+ }
292
+ this.write("Please answer y or n.\n");
293
+ }
294
+ }
295
+
296
+ async promptChoice(question, options, { defaultIndex = 0, answerKey = null } = {}) {
297
+ this.write(`${question}\n`);
298
+ options.forEach((option, index) => {
299
+ this.write(` [${index + 1}] ${option.label}\n`);
300
+ });
301
+ for (;;) {
302
+ const raw = await this.readLine(
303
+ `Select 1-${options.length} [${defaultIndex + 1}]: `,
304
+ { mask: false, answerKey, defaultValue: String(defaultIndex + 1) }
305
+ );
306
+ const answer = raw.trim();
307
+ if (answer === "") {
308
+ return options[defaultIndex].value;
309
+ }
310
+ const matched = options.find(
311
+ (o) =>
312
+ String(o.value).toLowerCase() === answer.toLowerCase() ||
313
+ o.label.toLowerCase() === answer.toLowerCase()
314
+ );
315
+ if (matched) {
316
+ return matched.value;
317
+ }
318
+ const index = Number.parseInt(answer, 10);
319
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) {
320
+ return options[index - 1].value;
321
+ }
322
+ this.write(`Invalid choice. Enter a number between 1 and ${options.length}.\n`);
323
+ }
324
+ }
325
+
326
+ async finalConfirmation(lines, { answerKey = "approvePlan" } = {}) {
327
+ this.write("SpaceApp setup plan:\n");
328
+ for (const line of lines) {
329
+ this.write(` - ${line}\n`);
330
+ }
331
+ const approved = await this.promptYesNo("Apply this plan?", { defaultYes: false, answerKey });
332
+ if (!approved) {
333
+ this.write("Cancelled. No changes were made.\n");
334
+ }
335
+ return approved;
336
+ }
337
+
338
+ async close() {
339
+ if (this.logStream) {
340
+ await new Promise((resolve) => this.logStream.end(resolve));
341
+ this.logStream = null;
342
+ }
343
+ }
344
+ }
@@ -0,0 +1,159 @@
1
+ import { Writable } from "node:stream";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { composeCommand, profileRuntimeSettings, PROFILE_REQUIREMENTS } from "./index.mjs";
5
+
6
+ const GiB = 1024 ** 3;
7
+ const MiB = 1024 ** 2;
8
+ // Upper bounds include extraction/transfer headroom, not the size of existing data.
9
+ const imageReserve = { core: 1.4 * GiB, cli: 3.8 * GiB, browser: 2.2 * GiB, postgres: 700 * MiB, temporal: 800 * MiB };
10
+
11
+ export async function captureProbe(execute, spec) {
12
+ let output = "";
13
+ const stdout = new Writable({ write(chunk, _encoding, done) {
14
+ if (output.length < 1024 * 1024) output += chunk.toString();
15
+ done();
16
+ } });
17
+ try {
18
+ const code = await execute({ ...spec, timeoutMs: 15000 }, { stdout, stderr: null });
19
+ return code === 0 ? output.trim() : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ async function configurationBytes(root) {
24
+ let bytes = 0;
25
+ // Checkpoints copy configuration and secrets; workspaces/volumes are retained in place.
26
+ async function walk(path) {
27
+ for (const entry of await readdir(path, { withFileTypes: true }).catch(() => [])) {
28
+ const child = join(path, entry.name);
29
+ if (entry.isDirectory()) await walk(child);
30
+ else if (entry.isFile()) bytes += (await stat(child)).size;
31
+ }
32
+ }
33
+ await walk(join(root, "secrets"));
34
+ for (const file of ["config.json", "runtime.env", "compose.yml", "compose.workspaces.yml", "compose.host-access.yml"]) {
35
+ bytes += (await stat(join(root, file)).catch(() => ({ size: 0 }))).size;
36
+ }
37
+ return bytes;
38
+ }
39
+
40
+ export function calculateStoragePlan({ operation, profile, missingImageBytes = 0, databaseBytes = 0, configBytes = 0, databaseSizeKnown = true }) {
41
+ const checkpointBytes = operation === "upgrade"
42
+ ? Math.max(64 * MiB, databaseSizeKnown ? databaseBytes * 3 + configBytes : 512 * MiB + configBytes)
43
+ : 0;
44
+ return {
45
+ operation, missingImageBytes, checkpointBytes, databaseSizeKnown,
46
+ requiredDiskBytes: Math.ceil(missingImageBytes + checkpointBytes + 512 * MiB),
47
+ freshDiskBytes: PROFILE_REQUIREMENTS[profile].freshDiskBytes
48
+ };
49
+ }
50
+
51
+ export function estimateMissingLayers(layers, diffIds, cachedLayers) {
52
+ if (!Array.isArray(layers) || layers.length !== diffIds?.length) throw new Error("Invalid image layer metadata.");
53
+ let bytes = 0;
54
+ for (let i = 0; i < layers.length; i++) {
55
+ if (cachedLayers.has(diffIds[i])) continue;
56
+ if (!Number.isSafeInteger(layers[i].size) || layers[i].size < 0) throw new Error("Invalid image layer size.");
57
+ bytes += Math.max(64 * MiB, layers[i].size * 5);
58
+ }
59
+ return bytes;
60
+ }
61
+
62
+ async function registryLayers(image, arch, fetch) {
63
+ const match = /^ghcr\.io\/(oll4com\/spaceapp-(?:core|cli|browser)):([0-9]+\.[0-9]+\.[0-9]+)$/.exec(image);
64
+ if (!match) return null;
65
+ const repository = match[1], tag = match[2];
66
+ const requestJson = async (url, headers = {}) => {
67
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(8000), redirect: "follow" });
68
+ if (!response.ok) throw new Error("Public image metadata unavailable.");
69
+ const text = await response.text();
70
+ if (text.length > 2 * 1024 * 1024) throw new Error("Oversized image metadata.");
71
+ return JSON.parse(text);
72
+ };
73
+ const credentials = await requestJson(`https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull`);
74
+ if (typeof credentials.token !== "string") return null;
75
+ const headers = { authorization: `Bearer ${credentials.token}`, accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" };
76
+ let manifest = await requestJson(`https://ghcr.io/v2/${repository}/manifests/${tag}`, headers);
77
+ if (manifest.manifests) {
78
+ const descriptor = manifest.manifests.find((item) => item.platform?.os === "linux" && item.platform?.architecture === arch);
79
+ if (!/^sha256:[a-f0-9]{64}$/.test(descriptor?.digest ?? "")) return null;
80
+ manifest = await requestJson(`https://ghcr.io/v2/${repository}/manifests/${descriptor.digest}`, headers);
81
+ }
82
+ if (!/^sha256:[a-f0-9]{64}$/.test(manifest.config?.digest ?? "")) return null;
83
+ const config = await requestJson(`https://ghcr.io/v2/${repository}/blobs/${manifest.config.digest}`, headers);
84
+ return { layers: manifest.layers, diffIds: config.rootfs?.diff_ids };
85
+ }
86
+
87
+ export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) {
88
+ const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] });
89
+ let engine = null;
90
+ try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ }
91
+ const cachedLayers = new Set();
92
+ if (engine && existingConfig) {
93
+ for (const type of ["core", "cli", "browser"]) {
94
+ const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", `ghcr.io/oll4com/spaceapp-${type}:${existingConfig.version}`] });
95
+ try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); } catch {}
96
+ }
97
+ }
98
+ const settings = profileRuntimeSettings(config.profile);
99
+ const images = [
100
+ ["core", `ghcr.io/oll4com/spaceapp-core:${config.version}`],
101
+ ["cli", `ghcr.io/oll4com/spaceapp-cli:${config.version}`],
102
+ ["postgres", "pgvector/pgvector:0.8.3-pg17-trixie"],
103
+ ...(settings.browserEnabled ? [["browser", `ghcr.io/oll4com/spaceapp-browser:${config.version}`]] : []),
104
+ ...(settings.temporalEnabled ? [["temporal", "temporalio/auto-setup:1.29.1"]] : []),
105
+ ...(config.companionsEnabled ? [["companion", "ghcr.io/soju06/codex-lb:1.22.0"], ["companion", "ghcr.io/oll4com/headroom-codex-lb:code"]] : [])
106
+ ];
107
+ let missingImageBytes = 0;
108
+ const missingImages = [];
109
+ let sharedLayersEstimated = false;
110
+ for (const [type, image] of images) {
111
+ const cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", image] });
112
+ if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) {
113
+ missingImages.push(image);
114
+ let reserve = imageReserve[type] ?? GiB;
115
+ if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") {
116
+ try {
117
+ const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? "") ? "arm64" : "amd64", fetch);
118
+ if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; }
119
+ } catch { /* Offline/private registry: retain the full conservative reserve. */ }
120
+ }
121
+ missingImageBytes += reserve;
122
+ }
123
+ }
124
+ let databaseBytes = 0, databaseSizeKnown = true;
125
+ if (operation === "upgrade") {
126
+ const size = await captureProbe(execute, composeCommand("databaseSize", root, existingConfig ?? config));
127
+ databaseSizeKnown = /^\d+$/.test(size ?? "");
128
+ if (databaseSizeKnown) databaseBytes = Number(size);
129
+ }
130
+ const plan = calculateStoragePlan({ operation, profile: config.profile, missingImageBytes, databaseBytes,
131
+ configBytes: await configurationBytes(root), databaseSizeKnown });
132
+ let dockerFreeDiskBytes = null;
133
+ if (existingConfig) {
134
+ const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig));
135
+ if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free);
136
+ }
137
+ // Linux can measure the Docker filesystem directly even on a fresh install.
138
+ if (engine?.DockerRootDir && process.platform === "linux") {
139
+ const { inspectSystemResources } = await import("./index.mjs");
140
+ try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {}
141
+ }
142
+ return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes,
143
+ resources: { ...resources,
144
+ freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes),
145
+ engineCpuCount: engine?.NCPU,
146
+ engineMemoryBytes: engine?.MemTotal
147
+ } };
148
+ }
149
+
150
+ export function storageChecks(plan, resources) {
151
+ const checks = [{ name: "Free disk", ok: resources.freeDiskBytes >= plan.requiredDiskBytes,
152
+ detail: `${gib(resources.freeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally for ${plan.operation} (images ${gib(plan.missingImageBytes)}, checkpoint ${gib(plan.checkpointBytes)}, reserve 0.5)` }];
153
+ if (plan.dockerFreeDiskBytes !== null) checks.push({ name: "Docker storage", ok: plan.dockerFreeDiskBytes >= plan.requiredDiskBytes,
154
+ detail: `${gib(plan.dockerFreeDiskBytes)} GiB available; ${gib(plan.requiredDiskBytes)} GiB required additionally` });
155
+ else checks.push({ name: "Docker storage", ok: true, detail: "Separate engine disk capacity unavailable; host estimate used. Docker ENOSPC will stop the operation before cutover." });
156
+ return checks;
157
+ }
158
+
159
+ function gib(bytes) { return (bytes / GiB).toFixed(2); }
@@ -0,0 +1,147 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "version": "1.0.0",
4
+ "generatedAt": "2026-09-28T14:00:00Z",
5
+ "launcher": {
6
+ "name": "run-spaceapp",
7
+ "minNodeVersion": ">=20.11",
8
+ "recommendedNode": "22.x",
9
+ "ltsLines": ["20.x", "22.x"]
10
+ },
11
+ "runtime": {
12
+ "profiles": [
13
+ {
14
+ "id": "light",
15
+ "description": "Minimal memory footprint, essential services only (spaceapp-core, spaceapp-cli, postgres, temporal)",
16
+ "minMemoryMb": 4096,
17
+ "recommendedMemoryMb": 8192
18
+ },
19
+ {
20
+ "id": "standard",
21
+ "description": "Full desktop & browser workspace capability (+ automated browser host, companions)",
22
+ "minMemoryMb": 8192,
23
+ "recommendedMemoryMb": 16384
24
+ }
25
+ ],
26
+ "companionsToggle": true,
27
+ "telemetryToggle": true
28
+ },
29
+ "platforms": {
30
+ "windows": {
31
+ "supported": true,
32
+ "variants": [
33
+ {
34
+ "id": "win11-x64",
35
+ "os": "Windows 11",
36
+ "arch": "x64",
37
+ "shells": ["cmd", "powershell5", "powershell7"],
38
+ "terminals": ["conhost", "windowsterminal"],
39
+ "docker": {
40
+ "type": "Docker Desktop",
41
+ "minVersion": "4.30.0",
42
+ "backend": "WSL2"
43
+ },
44
+ "executionPolicyBypass": "npx.cmd / spaceapp.cmd (no machine policy change)"
45
+ },
46
+ {
47
+ "id": "win11-arm64",
48
+ "os": "Windows 11",
49
+ "arch": "arm64",
50
+ "docker": {
51
+ "type": "Docker Desktop",
52
+ "minVersion": "4.30.0",
53
+ "backend": "WSL2 ARM64"
54
+ }
55
+ }
56
+ ]
57
+ },
58
+ "macos": {
59
+ "supported": true,
60
+ "variants": [
61
+ {
62
+ "id": "macos-intel",
63
+ "os": "macOS 13+ (Ventura / Sonoma / Sequoia)",
64
+ "arch": "x64",
65
+ "shells": ["zsh", "bash"],
66
+ "docker": {
67
+ "type": "Docker Desktop",
68
+ "minVersion": "4.30.0"
69
+ }
70
+ },
71
+ {
72
+ "id": "macos-apple-silicon",
73
+ "os": "macOS 13+ (Ventura / Sonoma / Sequoia)",
74
+ "arch": "arm64",
75
+ "shells": ["zsh", "bash"],
76
+ "docker": {
77
+ "type": "Docker Desktop",
78
+ "minVersion": "4.30.0"
79
+ }
80
+ }
81
+ ]
82
+ },
83
+ "linux": {
84
+ "supported": true,
85
+ "variants": [
86
+ {
87
+ "id": "ubuntu-debian-x64",
88
+ "families": ["Ubuntu 22.04 LTS", "Ubuntu 24.04 LTS", "Debian 12 Bookworm"],
89
+ "arch": "x64",
90
+ "packageManagers": ["apt"],
91
+ "docker": {
92
+ "type": "Docker Engine / Compose v2",
93
+ "installMethod": "official-docker-apt-repo"
94
+ }
95
+ },
96
+ {
97
+ "id": "ubuntu-debian-arm64",
98
+ "families": ["Ubuntu 22.04 LTS", "Ubuntu 24.04 LTS", "Debian 12 Bookworm"],
99
+ "arch": "arm64",
100
+ "packageManagers": ["apt"],
101
+ "docker": {
102
+ "type": "Docker Engine / Compose v2",
103
+ "installMethod": "official-docker-apt-repo"
104
+ }
105
+ },
106
+ {
107
+ "id": "fedora-rhel-x64",
108
+ "families": ["Fedora 39+", "RHEL 9", "CentOS Stream 9"],
109
+ "arch": "x64",
110
+ "packageManagers": ["dnf5", "dnf"],
111
+ "docker": {
112
+ "type": "Docker Engine / Compose v2",
113
+ "installMethod": "official-docker-dnf-repo"
114
+ }
115
+ },
116
+ {
117
+ "id": "arch-cachyos-x64",
118
+ "families": ["Arch Linux", "CachyOS"],
119
+ "arch": "x64",
120
+ "packageManagers": ["pacman"],
121
+ "docker": {
122
+ "type": "Docker Engine / Compose v2",
123
+ "installMethod": "pacman-official-repo"
124
+ }
125
+ }
126
+ ]
127
+ }
128
+ },
129
+ "fixtures": [
130
+ {
131
+ "id": "clean_os",
132
+ "description": "Base OS without Node.js or Docker"
133
+ },
134
+ {
135
+ "id": "node_only",
136
+ "description": "Node.js LTS installed, Docker missing or inactive"
137
+ },
138
+ {
139
+ "id": "prereqs_ready",
140
+ "description": "Node.js and Docker active and ready"
141
+ },
142
+ {
143
+ "id": "existing_install",
144
+ "description": "Previous SpaceApp install with database, test user, workspace markers"
145
+ }
146
+ ]
147
+ }