run-spaceapp 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,10 +20,19 @@ Windows 11 PowerShell:
20
20
  npm install -g run-spaceapp; if ($LASTEXITCODE -eq 0) { spaceapp install }
21
21
  ```
22
22
 
23
- The launcher requires Node.js 20.11 or newer, 4 CPUs, 8 GB RAM, 15 GiB free
24
- disk, and Docker Compose. `spaceapp install` initializes, checks, downloads,
25
- starts, and opens SpaceApp. It selects the `light` profile below 12 GiB RAM and
26
- the `standard` profile otherwise; override this with `--profile light` or
23
+ The launcher requires Node.js 20.11 or newer, 4 CPUs, 8 GB RAM, and 15 GiB free
24
+ disk. If Docker is missing, `spaceapp install` automatically installs it from
25
+ official Docker sources on Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and
26
+ CentOS, starts it, and waits for Engine and Compose readiness before pulling
27
+ images. Windows prefers Windows Package Manager's hash-pinned Docker Desktop
28
+ manifest and retains a signed direct-download fallback. Docker Desktop license
29
+ acceptance and Linux `docker` group membership require confirmation inside the
30
+ same command. On Windows, approve any UAC prompt required by WSL2 or Docker
31
+ Desktop. If Windows requests one restart, rerun the same command afterward and
32
+ installation continues safely.
33
+
34
+ The launcher selects the `light` profile below 12 GiB RAM and the `standard`
35
+ profile otherwise; override this with `--profile light` or
27
36
  `--profile standard`.
28
37
 
29
38
  Light mode retains every bundled CLI and the core data/workflow services while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-spaceapp",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "scripts": {
31
31
  "test": "node --test tests/*.test.mjs",
32
- "check": "node --check bin/spaceapp.mjs && node --check src/index.mjs && node --check src/cli.mjs",
32
+ "check": "node --check bin/spaceapp.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/prerequisites.mjs",
33
33
  "pack:dry-run": "npm pack --dry-run"
34
34
  },
35
35
  "repository": {
@@ -44,5 +44,6 @@
44
44
  "publishConfig": {
45
45
  "access": "public",
46
46
  "provenance": true
47
- }
47
+ },
48
+ "gitHead": "81bf4f16666f75cec5b499df28115df3c28e36b8"
48
49
  }
package/src/cli.mjs CHANGED
@@ -20,15 +20,39 @@ import {
20
20
  writeRuntimeFiles,
21
21
  writeSetupToken
22
22
  } from "./index.mjs";
23
+ import {
24
+ ensureDockerAvailable,
25
+ windowsPowerShellArgs
26
+ } from "./prerequisites.mjs";
27
+
28
+ const trustedCommands = new Set([
29
+ "codesign",
30
+ "docker",
31
+ "explorer.exe",
32
+ "hdiutil",
33
+ "open",
34
+ "powershell.exe",
35
+ "sg",
36
+ "spctl",
37
+ "sudo",
38
+ "winget.exe",
39
+ "wsl.exe",
40
+ "xdg-open"
41
+ ]);
42
+ const trustedEnvironmentNames = new Set([
43
+ "SPACEAPP_DOCKER_INSTALLER_PATH"
44
+ ]);
23
45
 
24
46
  export async function run(argv, {
25
47
  env = process.env,
26
48
  platform = process.platform,
49
+ arch = process.arch,
27
50
  stdout = process.stdout,
28
51
  stderr = process.stderr,
29
52
  stdin = process.stdin,
30
53
  execute = executeCommand,
31
- inspectResources = inspectSystemResources
54
+ inspectResources = inspectSystemResources,
55
+ ensureDocker = ensureDockerAvailable
32
56
  } = {}) {
33
57
  const [command = "help", ...args] = argv;
34
58
  const root = resolveSpaceAppHome({ env, platform });
@@ -47,11 +71,14 @@ export async function run(argv, {
47
71
  root,
48
72
  version,
49
73
  platform,
74
+ arch,
75
+ env,
50
76
  stdin,
51
77
  stdout,
52
78
  stderr,
53
79
  execute,
54
- inspectResources
80
+ inspectResources,
81
+ ensureDocker
55
82
  });
56
83
  }
57
84
  if (command === "init") {
@@ -173,11 +200,14 @@ async function installCommand(args, {
173
200
  root,
174
201
  version,
175
202
  platform,
203
+ arch,
204
+ env,
176
205
  stdin,
177
206
  stdout,
178
207
  stderr,
179
208
  execute,
180
- inspectResources
209
+ inspectResources,
210
+ ensureDocker
181
211
  }) {
182
212
  const { requestedProfile, noOpen } = parseInstallArgs(args);
183
213
  const resources = await inspectResources(root);
@@ -193,6 +223,38 @@ async function installCommand(args, {
193
223
  stdout.write("Store it temporarily; it expires after first owner setup.\n");
194
224
  }
195
225
 
226
+ if (installResourceChecks(resources).some((check) => !check.ok)) {
227
+ const doctorCode = await doctor({
228
+ root,
229
+ platform,
230
+ stdout,
231
+ stderr,
232
+ execute,
233
+ stdin,
234
+ inspectResources,
235
+ resources
236
+ });
237
+ stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
238
+ return doctorCode;
239
+ }
240
+
241
+ const prerequisiteResult = await ensureDocker({
242
+ platform,
243
+ arch,
244
+ env,
245
+ stdin,
246
+ stdout,
247
+ stderr,
248
+ execute,
249
+ installArgs: { requestedProfile, noOpen }
250
+ });
251
+ if (prerequisiteResult.reexecuted || prerequisiteResult.code !== 0) {
252
+ if (prerequisiteResult.code !== 0) {
253
+ stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
254
+ }
255
+ return prerequisiteResult.code;
256
+ }
257
+
196
258
  const doctorCode = await doctor({
197
259
  root,
198
260
  platform,
@@ -201,7 +263,8 @@ async function installCommand(args, {
201
263
  execute,
202
264
  stdin,
203
265
  inspectResources,
204
- resources
266
+ resources,
267
+ dockerReady: true
205
268
  });
206
269
  if (doctorCode !== 0) {
207
270
  stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
@@ -392,7 +455,8 @@ async function doctor({
392
455
  execute,
393
456
  stdin,
394
457
  inspectResources,
395
- resources
458
+ resources,
459
+ dockerReady = false
396
460
  }) {
397
461
  const detectedResources = resources ?? await inspectResources(root);
398
462
  const checks = [
@@ -403,9 +467,12 @@ async function doctor({
403
467
  let dockerMissing = false;
404
468
  for (const probe of [
405
469
  { name: "Docker", command: "docker", args: ["--version"] },
406
- { name: "Docker Compose", command: "docker", args: ["compose", "version"] }
470
+ { name: "Docker Compose", command: "docker", args: ["compose", "version"] },
471
+ { name: "Docker Engine", command: "docker", args: ["info"] }
407
472
  ]) {
408
- const code = await execute(probe, { stdin, stdout: null, stderr: null });
473
+ const code = dockerReady
474
+ ? 0
475
+ : await execute(probe, { stdin, stdout: null, stderr: null });
409
476
  if (code !== 0) dockerMissing = true;
410
477
  checks.push({ name: probe.name, ok: code === 0, detail: code === 0 ? "available" : "missing" });
411
478
  }
@@ -462,7 +529,29 @@ export async function readSecret(stdin, stdout, prompt, { mask = true } = {}) {
462
529
 
463
530
  export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
464
531
  return new Promise((resolve, reject) => {
465
- const child = spawn(spec.command, spec.args, {
532
+ if (!spec || typeof spec !== "object" || !trustedCommands.has(spec.command)) {
533
+ reject(new Error("SpaceApp refused to execute an untrusted command."));
534
+ return;
535
+ }
536
+ if (spec.command === "powershell.exe" && (
537
+ spec.args !== undefined ||
538
+ typeof spec.operation !== "string"
539
+ )) {
540
+ reject(new Error("SpaceApp PowerShell commands must use a trusted operation."));
541
+ return;
542
+ }
543
+ if (spec.command !== "powershell.exe" && (
544
+ !Array.isArray(spec.args) ||
545
+ spec.args.some((argument) => typeof argument !== "string")
546
+ )) {
547
+ reject(new Error("SpaceApp command arguments must be strings."));
548
+ return;
549
+ }
550
+ const commandEnv = spec.env === undefined
551
+ ? process.env
552
+ : validateCommandEnvironment(spec.env);
553
+ const child = spawnTrustedCommand(spec, {
554
+ env: commandEnv,
466
555
  shell: false,
467
556
  stdio: [input === undefined ? (stdin || "inherit") : "pipe", stdout || "ignore", stderr || "ignore"]
468
557
  });
@@ -482,12 +571,46 @@ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
482
571
  });
483
572
  }
484
573
 
574
+ function spawnTrustedCommand(spec, options) {
575
+ const args = spec.args;
576
+ switch (spec.command) {
577
+ case "codesign": return spawn("codesign", args, options);
578
+ case "docker": return spawn("docker", args, options);
579
+ case "explorer.exe": return spawn("explorer.exe", args, options);
580
+ case "hdiutil": return spawn("hdiutil", args, options);
581
+ case "open": return spawn("open", args, options);
582
+ case "powershell.exe":
583
+ return spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options);
584
+ case "sg": return spawn("sg", args, options);
585
+ case "spctl": return spawn("spctl", args, options);
586
+ case "sudo": return spawn("sudo", args, options);
587
+ case "winget.exe": return spawn("winget.exe", args, options);
588
+ case "wsl.exe": return spawn("wsl.exe", args, options);
589
+ case "xdg-open": return spawn("xdg-open", args, options);
590
+ default: throw new Error("SpaceApp refused to execute an untrusted command.");
591
+ }
592
+ }
593
+
594
+ function validateCommandEnvironment(commandEnvironment) {
595
+ if (
596
+ !commandEnvironment ||
597
+ typeof commandEnvironment !== "object" ||
598
+ Array.isArray(commandEnvironment) ||
599
+ Object.entries(commandEnvironment).some(
600
+ ([name, value]) => !trustedEnvironmentNames.has(name) || typeof value !== "string"
601
+ )
602
+ ) {
603
+ throw new Error("SpaceApp refused untrusted command environment values.");
604
+ }
605
+ return { ...process.env, ...commandEnvironment };
606
+ }
607
+
485
608
  function openBrowser(url, platform, execute, io) {
486
609
  if (platform === "darwin") {
487
610
  return execute({ command: "open", args: [url] }, io);
488
611
  }
489
612
  if (platform === "win32") {
490
- return execute({ command: "cmd", args: ["/d", "/s", "/c", "start", "", url] }, io);
613
+ return execute({ command: "explorer.exe", args: [url] }, io);
491
614
  }
492
615
  return execute({ command: "xdg-open", args: [url] }, io);
493
616
  }
@@ -500,12 +623,12 @@ function assertNoArgs(args, command) {
500
623
 
501
624
  function dockerInstallHelp(platform) {
502
625
  if (platform === "win32") {
503
- return "Install Docker Desktop with its WSL2 backend, start it, then run spaceapp install again: https://docs.docker.com/desktop/setup/install/windows-install/";
626
+ return 'Run "spaceapp install" to install and start signed Docker Desktop with WSL2 automatically.';
504
627
  }
505
628
  if (platform === "darwin") {
506
- return "Install and start Docker Desktop for macOS, then run spaceapp install again: https://docs.docker.com/desktop/setup/install/mac-install/";
629
+ return 'Run "spaceapp install" to install and start signed Docker Desktop automatically.';
507
630
  }
508
- return "Install Docker Engine and the Docker Compose plugin, then run spaceapp install again: https://docs.docker.com/engine/install/";
631
+ return 'Run "spaceapp install" to install and start Docker Engine and Compose automatically on supported Linux distributions.';
509
632
  }
510
633
 
511
634
  function formatGigabytes(bytes) {
@@ -524,10 +647,10 @@ Usage: spaceapp <command>
524
647
 
525
648
  init Create a local SpaceApp installation
526
649
  install [--profile auto|light|standard] [--no-open]
527
- Initialize, check, download, start, and open
650
+ Install prerequisites, initialize, and start
528
651
  up | down | status | logs Manage the Docker application
529
652
  open Open the local web application
530
- doctor Check Node, Docker, and Compose
653
+ doctor Check resources, Docker, Compose, and engine
531
654
  update [version] | rollback Update or roll back images
532
655
  backup | restore Back up or restore persistent state
533
656
  workspace add <path> [--read-only]
@@ -0,0 +1,815 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { delimiter, join, posix, win32 } from "node:path";
5
+ import process from "node:process";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { Readable } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { spawn } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/";
13
+ const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000;
14
+ const WINDOWS_DOCKER_DOWNLOADS = Object.freeze({
15
+ x64: "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe",
16
+ arm64: "https://desktop.docker.com/win/main/arm64/Docker%20Desktop%20Installer.exe"
17
+ });
18
+ const MAC_DOCKER_DOWNLOADS = Object.freeze({
19
+ x64: "https://desktop.docker.com/mac/main/amd64/Docker.dmg",
20
+ arm64: "https://desktop.docker.com/mac/main/arm64/Docker.dmg"
21
+ });
22
+ // Sources:
23
+ // https://learn.microsoft.com/windows/package-manager/winget/install
24
+ // https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/Docker/DockerDesktop
25
+ const WINDOWS_DOCKER_WINGET_ARGS = Object.freeze([
26
+ "install",
27
+ "--id", "Docker.DockerDesktop",
28
+ "--exact",
29
+ "--source", "winget",
30
+ "--silent",
31
+ "--accept-package-agreements",
32
+ "--accept-source-agreements",
33
+ "--disable-interactivity"
34
+ ]);
35
+
36
+ export function windowsPowerShellArgs(operation) {
37
+ const scripts = {
38
+ "install-wsl": [
39
+ "$ErrorActionPreference = 'Stop'; try {",
40
+ "$process = Start-Process -FilePath 'wsl.exe'",
41
+ "-ArgumentList @('--install','--no-distribution')",
42
+ "-Verb RunAs -Wait -PassThru",
43
+ "; exit $process.ExitCode",
44
+ "} catch { exit 1 }"
45
+ ].join(" "),
46
+ "verify-docker-installer": [
47
+ "$path = $env:SPACEAPP_DOCKER_INSTALLER_PATH",
48
+ "if ([string]::IsNullOrWhiteSpace($path)) { exit 1 }",
49
+ "$signature = Get-AuthenticodeSignature -LiteralPath $path",
50
+ "if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Docker') { exit 1 }"
51
+ ].join("; "),
52
+ "install-docker-desktop": [
53
+ "$path = $env:SPACEAPP_DOCKER_INSTALLER_PATH",
54
+ "if ([string]::IsNullOrWhiteSpace($path)) { exit 1 }",
55
+ "$process = Start-Process -FilePath $path -ArgumentList @('install','--user','--quiet','--accept-license') -Wait -PassThru",
56
+ "exit $process.ExitCode"
57
+ ].join("; "),
58
+ "start-docker-desktop": [
59
+ "$path = $env:SPACEAPP_DOCKER_DESKTOP_PATH",
60
+ "if ([string]::IsNullOrWhiteSpace($path)) { exit 1 }",
61
+ "Start-Process -FilePath $path"
62
+ ].join("; ")
63
+ };
64
+ if (!Object.hasOwn(scripts, operation)) {
65
+ throw new Error("SpaceApp refused an untrusted PowerShell operation.");
66
+ }
67
+ return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", scripts[operation]];
68
+ }
69
+
70
+ export async function ensureDockerAvailable({
71
+ platform = process.platform,
72
+ arch = process.arch,
73
+ env = process.env,
74
+ stdin = process.stdin,
75
+ stdout = process.stdout,
76
+ stderr = process.stderr,
77
+ execute,
78
+ launch = launchDetachedCommand,
79
+ download = downloadFile,
80
+ pathExists = fileExists,
81
+ sleep = wait,
82
+ osRelease,
83
+ installArgs
84
+ }) {
85
+ if (await dockerIsReady(execute)) {
86
+ return { code: 0, reexecuted: false };
87
+ }
88
+ if (platform === "win32") {
89
+ return ensureWindowsDocker({
90
+ arch,
91
+ env,
92
+ stdin,
93
+ stdout,
94
+ stderr,
95
+ execute,
96
+ launch,
97
+ download,
98
+ pathExists,
99
+ sleep
100
+ });
101
+ }
102
+ if (platform === "darwin") {
103
+ return ensureMacDocker({
104
+ arch,
105
+ env,
106
+ stdin,
107
+ stdout,
108
+ stderr,
109
+ execute,
110
+ launch,
111
+ download,
112
+ pathExists,
113
+ sleep
114
+ });
115
+ }
116
+ return ensureLinuxDocker({
117
+ arch,
118
+ env,
119
+ stdin,
120
+ stdout,
121
+ stderr,
122
+ execute,
123
+ download,
124
+ sleep,
125
+ osRelease,
126
+ installArgs
127
+ });
128
+ }
129
+
130
+ async function ensureWindowsDocker({
131
+ arch,
132
+ env,
133
+ stdin,
134
+ stdout,
135
+ stderr,
136
+ execute,
137
+ launch,
138
+ download,
139
+ pathExists,
140
+ sleep
141
+ }) {
142
+ const paths = windowsDockerPaths(env);
143
+ let application = await firstExisting(paths.applications, pathExists);
144
+
145
+ if (!application) {
146
+ stdout.write("Docker Desktop is required and is not installed.\n");
147
+ const accepted = await confirmDesktopInstall({ platformName: "Windows", stdin, stdout });
148
+ if (!accepted) {
149
+ stderr.write("Docker Desktop installation was cancelled.\n");
150
+ return { code: 1, reexecuted: false };
151
+ }
152
+ }
153
+
154
+ const wslCode = await execute(
155
+ { command: "wsl.exe", args: ["--version"] },
156
+ { stdin, stdout: null, stderr: null }
157
+ );
158
+ if (wslCode !== 0) {
159
+ stdout.write("Installing the Windows Subsystem for Linux 2 support required by Docker Desktop...\n");
160
+ const installWslCode = await execute(
161
+ {
162
+ command: "powershell.exe",
163
+ operation: "install-wsl"
164
+ },
165
+ { stdin, stdout, stderr }
166
+ );
167
+ if (installWslCode !== 0) {
168
+ stderr.write(
169
+ "Windows could not enable WSL2 automatically. Run this command from an Administrator terminal, restart Windows if requested, and run it again.\n"
170
+ );
171
+ return { code: installWslCode, reexecuted: false };
172
+ }
173
+ const installedWslCode = await execute(
174
+ { command: "wsl.exe", args: ["--version"] },
175
+ { stdin, stdout: null, stderr: null }
176
+ );
177
+ if (installedWslCode !== 0) {
178
+ stderr.write(
179
+ "Windows must restart to finish enabling WSL2. Restart Windows, then run the same SpaceApp command again.\n"
180
+ );
181
+ return { code: 1, reexecuted: false };
182
+ }
183
+ }
184
+
185
+ if (!application) {
186
+ const wingetCode = await execute(
187
+ { command: "winget.exe", args: ["--version"] },
188
+ { stdin, stdout: null, stderr: null }
189
+ );
190
+ if (wingetCode === 0) {
191
+ stdout.write(
192
+ "Installing Docker Desktop through Windows Package Manager with manifest hash verification...\n"
193
+ );
194
+ const installCode = await execute(
195
+ { command: "winget.exe", args: [...WINDOWS_DOCKER_WINGET_ARGS] },
196
+ { stdin, stdout, stderr }
197
+ );
198
+ if (installCode === 0) {
199
+ application = await firstExisting(paths.applications, pathExists);
200
+ if (!application) {
201
+ stderr.write(
202
+ "Windows Package Manager reported success, but Docker Desktop could not be found.\n"
203
+ );
204
+ return { code: 1, reexecuted: false };
205
+ }
206
+ } else {
207
+ stdout.write(
208
+ "Windows Package Manager could not install Docker Desktop; trying Docker's signed direct installer...\n"
209
+ );
210
+ }
211
+ }
212
+ }
213
+
214
+ if (!application) {
215
+ const downloadUrl = WINDOWS_DOCKER_DOWNLOADS[arch];
216
+ if (!downloadUrl) {
217
+ stderr.write(`Docker Desktop automatic installation does not support Windows architecture "${arch}".\n`);
218
+ return { code: 1, reexecuted: false };
219
+ }
220
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "spaceapp-docker-"));
221
+ const installer = join(temporaryRoot, "Docker Desktop Installer.exe");
222
+ const installerEnv = {
223
+ SPACEAPP_DOCKER_INSTALLER_PATH: installer
224
+ };
225
+ try {
226
+ stdout.write("Downloading Docker Desktop from Docker's official distribution service...\n");
227
+ await download(downloadUrl, installer);
228
+ const signatureCode = await execute({
229
+ command: "powershell.exe",
230
+ operation: "verify-docker-installer",
231
+ env: installerEnv
232
+ }, { stdin, stdout: null, stderr });
233
+ if (signatureCode !== 0) {
234
+ stderr.write("The Docker Desktop installer signature is not valid. Installation was stopped.\n");
235
+ return { code: 1, reexecuted: false };
236
+ }
237
+ const installCode = await execute(
238
+ {
239
+ command: "powershell.exe",
240
+ operation: "install-docker-desktop",
241
+ env: installerEnv
242
+ },
243
+ { stdin, stdout, stderr }
244
+ );
245
+ if (installCode !== 0) {
246
+ stderr.write("Docker Desktop installation failed before SpaceApp downloaded any images.\n");
247
+ return { code: installCode, reexecuted: false };
248
+ }
249
+ } finally {
250
+ await rm(temporaryRoot, { recursive: true, force: true });
251
+ }
252
+ application = await firstExisting(paths.applications, pathExists);
253
+ if (!application) {
254
+ stderr.write("Docker Desktop reported success, but its application could not be found.\n");
255
+ return { code: 1, reexecuted: false };
256
+ }
257
+ } else {
258
+ stdout.write("Docker Desktop is installed but is not running. Starting it now...\n");
259
+ }
260
+
261
+ const cliDirectory = await firstExisting(paths.cliDirectories, pathExists);
262
+ if (cliDirectory) {
263
+ prependPath(env, cliDirectory);
264
+ }
265
+ const launchCode = await launch({
266
+ operation: "start-docker-desktop",
267
+ env: {
268
+ SPACEAPP_DOCKER_DESKTOP_PATH: application
269
+ }
270
+ });
271
+ if (launchCode !== 0) {
272
+ stderr.write("Docker Desktop is installed, but SpaceApp could not start it.\n");
273
+ return { code: launchCode, reexecuted: false };
274
+ }
275
+ if (await waitForDocker({ execute, sleep })) {
276
+ stdout.write("Docker Desktop is ready.\n");
277
+ return { code: 0, reexecuted: false };
278
+ }
279
+ stderr.write(
280
+ "Docker Desktop was installed but did not become ready. Complete any Docker Desktop prompt or restart Windows if requested, then run the same SpaceApp command again.\n"
281
+ );
282
+ return { code: 1, reexecuted: false };
283
+ }
284
+
285
+ async function ensureMacDocker({
286
+ arch,
287
+ env,
288
+ stdin,
289
+ stdout,
290
+ stderr,
291
+ execute,
292
+ launch,
293
+ download,
294
+ pathExists,
295
+ sleep
296
+ }) {
297
+ const application = "/Applications/Docker.app";
298
+ const cliDirectory = posix.join(application, "Contents", "Resources", "bin");
299
+ let installed = await pathExists(application);
300
+
301
+ if (!installed) {
302
+ stdout.write("Docker Desktop is required and is not installed.\n");
303
+ const accepted = await confirmDesktopInstall({ platformName: "macOS", stdin, stdout });
304
+ if (!accepted) {
305
+ stderr.write("Docker Desktop installation was cancelled.\n");
306
+ return { code: 1, reexecuted: false };
307
+ }
308
+ const downloadUrl = MAC_DOCKER_DOWNLOADS[arch];
309
+ if (!downloadUrl) {
310
+ stderr.write(`Docker Desktop automatic installation does not support macOS architecture "${arch}".\n`);
311
+ return { code: 1, reexecuted: false };
312
+ }
313
+ const username = String(env.USER || "");
314
+ if (!/^[A-Za-z0-9._-]+$/.test(username)) {
315
+ stderr.write("A valid macOS username is required for Docker Desktop installation.\n");
316
+ return { code: 1, reexecuted: false };
317
+ }
318
+
319
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "spaceapp-docker-"));
320
+ const diskImage = join(temporaryRoot, "Docker.dmg");
321
+ const mountedApplication = "/Volumes/Docker/Docker.app";
322
+ const installer = posix.join(mountedApplication, "Contents", "MacOS", "install");
323
+ let mounted = false;
324
+ try {
325
+ stdout.write("Downloading Docker Desktop from Docker's official distribution service...\n");
326
+ await download(downloadUrl, diskImage);
327
+ const attachCode = await execute(
328
+ { command: "hdiutil", args: ["attach", "-nobrowse", diskImage] },
329
+ { stdin, stdout, stderr }
330
+ );
331
+ if (attachCode !== 0) {
332
+ return { code: attachCode, reexecuted: false };
333
+ }
334
+ mounted = true;
335
+ const signatureCode = await execute({
336
+ command: "codesign",
337
+ args: ["--verify", "--deep", "--strict", "--verbose=2", mountedApplication]
338
+ }, { stdin, stdout: null, stderr });
339
+ if (signatureCode !== 0) {
340
+ stderr.write("The Docker Desktop application signature is not valid. Installation was stopped.\n");
341
+ return { code: 1, reexecuted: false };
342
+ }
343
+ const gatekeeperCode = await execute({
344
+ command: "spctl",
345
+ args: ["--assess", "--type", "execute", "--verbose=4", mountedApplication]
346
+ }, { stdin, stdout: null, stderr });
347
+ if (gatekeeperCode !== 0) {
348
+ stderr.write("macOS Gatekeeper did not accept Docker Desktop. Installation was stopped.\n");
349
+ return { code: 1, reexecuted: false };
350
+ }
351
+ const installCode = await execute({
352
+ command: "sudo",
353
+ args: [installer, "--accept-license", `--user=${username}`]
354
+ }, { stdin, stdout, stderr });
355
+ if (installCode !== 0) {
356
+ stderr.write("Docker Desktop installation failed before SpaceApp downloaded any images.\n");
357
+ return { code: installCode, reexecuted: false };
358
+ }
359
+ } finally {
360
+ if (mounted) {
361
+ await execute(
362
+ { command: "hdiutil", args: ["detach", "/Volumes/Docker"] },
363
+ { stdin, stdout: null, stderr: null }
364
+ );
365
+ }
366
+ await rm(temporaryRoot, { recursive: true, force: true });
367
+ }
368
+ installed = await pathExists(application);
369
+ if (!installed) {
370
+ stderr.write("Docker Desktop reported success, but /Applications/Docker.app could not be found.\n");
371
+ return { code: 1, reexecuted: false };
372
+ }
373
+ } else {
374
+ stdout.write("Docker Desktop is installed but is not running. Starting it now...\n");
375
+ }
376
+
377
+ if (await pathExists(cliDirectory)) {
378
+ prependPath(env, cliDirectory);
379
+ }
380
+ const launchCode = await launch({ operation: "open-docker-desktop" });
381
+ if (launchCode !== 0) {
382
+ stderr.write("Docker Desktop is installed, but SpaceApp could not start it.\n");
383
+ return { code: launchCode, reexecuted: false };
384
+ }
385
+ if (await waitForDocker({ execute, sleep })) {
386
+ stdout.write("Docker Desktop is ready.\n");
387
+ return { code: 0, reexecuted: false };
388
+ }
389
+ stderr.write(
390
+ "Docker Desktop was installed but did not become ready. Complete any Docker Desktop prompt, then run the same SpaceApp command again.\n"
391
+ );
392
+ return { code: 1, reexecuted: false };
393
+ }
394
+
395
+ async function ensureLinuxDocker({
396
+ arch,
397
+ env,
398
+ stdin,
399
+ stdout,
400
+ stderr,
401
+ execute,
402
+ download,
403
+ osRelease,
404
+ installArgs
405
+ }) {
406
+ const cliAvailable = await dockerCliAndComposeAreAvailable(execute);
407
+ if (!cliAvailable) {
408
+ const release = osRelease ?? await readLinuxOsRelease();
409
+ stdout.write(`Docker Engine is required and is not installed. Installing it for ${release.ID}...\n`);
410
+ const installCode = await installLinuxDockerEngine({
411
+ arch,
412
+ release,
413
+ stdin,
414
+ stdout,
415
+ stderr,
416
+ execute,
417
+ download
418
+ });
419
+ if (installCode !== 0) {
420
+ return { code: installCode, reexecuted: false };
421
+ }
422
+ } else {
423
+ stdout.write("Docker Engine is installed but is not ready. Starting it now...\n");
424
+ }
425
+
426
+ const startCode = await execute(
427
+ { command: "sudo", args: ["systemctl", "enable", "--now", "docker"] },
428
+ { stdin, stdout, stderr }
429
+ );
430
+ if (startCode !== 0) {
431
+ stderr.write("Docker Engine was installed, but its system service could not be started.\n");
432
+ return { code: startCode, reexecuted: false };
433
+ }
434
+ if (await dockerIsReady(execute)) {
435
+ stdout.write("Docker Engine is ready.\n");
436
+ return { code: 0, reexecuted: false };
437
+ }
438
+ if (env.SPACEAPP_PREREQUISITES_BOOTSTRAPPED === "1") {
439
+ stderr.write("Docker Engine is running, but the current user still cannot access it.\n");
440
+ return { code: 1, reexecuted: false };
441
+ }
442
+
443
+ const username = String(env.SUDO_USER || env.USER || "");
444
+ if (!/^[A-Za-z0-9._-]+$/.test(username) || username === "root") {
445
+ stderr.write("A valid non-root Linux username is required for Docker Engine access.\n");
446
+ return { code: 1, reexecuted: false };
447
+ }
448
+ const accepted = await confirmQuestion({
449
+ stdin,
450
+ stdout,
451
+ question: [
452
+ "Docker's official post-install flow uses the docker group.",
453
+ "Membership grants root-level privileges on this host.",
454
+ `Add ${username} to the docker group and continue? [y/N] `
455
+ ].join("\n")
456
+ });
457
+ if (!accepted) {
458
+ stderr.write("Docker group membership was not changed.\n");
459
+ return { code: 1, reexecuted: false };
460
+ }
461
+ const groupCode = await execute(
462
+ { command: "sudo", args: ["usermod", "-aG", "docker", username] },
463
+ { stdin, stdout, stderr }
464
+ );
465
+ if (groupCode !== 0) {
466
+ return { code: groupCode, reexecuted: false };
467
+ }
468
+
469
+ const reentryCommand = linuxReentryCommand(installArgs);
470
+ stdout.write("Docker Engine is ready. Continuing SpaceApp installation with the new group membership...\n");
471
+ const reentryCode = await execute(
472
+ { command: "sg", args: ["docker", "-c", reentryCommand] },
473
+ { stdin, stdout, stderr }
474
+ );
475
+ return { code: reentryCode, reexecuted: true };
476
+ }
477
+
478
+ async function confirmDesktopInstall({ platformName, stdin, stdout }) {
479
+ return confirmQuestion({
480
+ stdin,
481
+ stdout,
482
+ question:
483
+ `Review the Docker Desktop terms at ${DOCKER_TERMS_URL}\n` +
484
+ `Install Docker Desktop for ${platformName} and accept those terms? [y/N] `
485
+ });
486
+ }
487
+
488
+ async function confirmQuestion({ stdin, stdout, question }) {
489
+ const readline = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin.isTTY) });
490
+ try {
491
+ const answer = await readline.question(question);
492
+ return /^(?:y|yes)$/i.test(answer.trim());
493
+ } catch {
494
+ return false;
495
+ } finally {
496
+ readline.close();
497
+ }
498
+ }
499
+
500
+ async function dockerCliAndComposeAreAvailable(execute) {
501
+ for (const spec of [
502
+ { command: "docker", args: ["--version"] },
503
+ { command: "docker", args: ["compose", "version"] }
504
+ ]) {
505
+ if (await execute(spec, { stdout: null, stderr: null }) !== 0) {
506
+ return false;
507
+ }
508
+ }
509
+ return true;
510
+ }
511
+
512
+ async function dockerIsReady(execute) {
513
+ for (const spec of [
514
+ { command: "docker", args: ["--version"] },
515
+ { command: "docker", args: ["compose", "version"] },
516
+ { command: "docker", args: ["info"] }
517
+ ]) {
518
+ if (await execute(spec, { stdout: null, stderr: null }) !== 0) {
519
+ return false;
520
+ }
521
+ }
522
+ return true;
523
+ }
524
+
525
+ async function installLinuxDockerEngine({
526
+ arch,
527
+ release,
528
+ stdin,
529
+ stdout,
530
+ stderr,
531
+ execute,
532
+ download
533
+ }) {
534
+ const id = String(release?.ID || "").toLowerCase();
535
+ if (id === "ubuntu" || id === "debian") {
536
+ return installAptDockerEngine({
537
+ arch,
538
+ id,
539
+ codename: release.VERSION_CODENAME || release.UBUNTU_CODENAME,
540
+ stdin,
541
+ stdout,
542
+ stderr,
543
+ execute,
544
+ download
545
+ });
546
+ }
547
+ if (id === "fedora" || id === "rhel" || id === "centos") {
548
+ return installDnfDockerEngine({ id, stdin, stdout, stderr, execute });
549
+ }
550
+ stderr.write(
551
+ `Automatic Docker Engine installation is not yet supported for Linux distribution "${id || "unknown"}". ` +
552
+ "Install Docker Engine and the Compose plugin from https://docs.docker.com/engine/install/ and rerun the same command.\n"
553
+ );
554
+ return 1;
555
+ }
556
+
557
+ async function installAptDockerEngine({
558
+ arch,
559
+ id,
560
+ codename,
561
+ stdin,
562
+ stdout,
563
+ stderr,
564
+ execute,
565
+ download
566
+ }) {
567
+ const dockerArch = { x64: "amd64", arm64: "arm64" }[arch];
568
+ if (!dockerArch || !/^[a-z0-9._-]+$/.test(String(codename || ""))) {
569
+ stderr.write("This Debian/Ubuntu architecture or release codename is not supported automatically.\n");
570
+ return 1;
571
+ }
572
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "spaceapp-docker-"));
573
+ const signingKey = join(temporaryRoot, "docker.asc");
574
+ const repositoryFile = join(temporaryRoot, "docker.list");
575
+ const repositoryBase = `https://download.docker.com/linux/${id}`;
576
+ try {
577
+ await download(`${repositoryBase}/gpg`, signingKey);
578
+ await writeFile(
579
+ repositoryFile,
580
+ `deb [arch=${dockerArch} signed-by=/etc/apt/keyrings/docker.asc] ${repositoryBase} ${codename} stable\n`,
581
+ { mode: 0o600, flag: "wx" }
582
+ );
583
+ for (const spec of [
584
+ { command: "sudo", args: ["install", "-m", "0755", "-d", "/etc/apt/keyrings"] },
585
+ { command: "sudo", args: ["install", "-m", "0644", signingKey, "/etc/apt/keyrings/docker.asc"] },
586
+ {
587
+ command: "sudo",
588
+ args: ["install", "-m", "0644", repositoryFile, "/etc/apt/sources.list.d/docker.list"]
589
+ },
590
+ { command: "sudo", args: ["apt-get", "update"] },
591
+ {
592
+ command: "sudo",
593
+ args: [
594
+ "apt-get",
595
+ "install",
596
+ "-y",
597
+ "docker-ce",
598
+ "docker-ce-cli",
599
+ "containerd.io",
600
+ "docker-buildx-plugin",
601
+ "docker-compose-plugin"
602
+ ]
603
+ }
604
+ ]) {
605
+ const code = await execute(spec, { stdin, stdout, stderr });
606
+ if (code !== 0) {
607
+ return code;
608
+ }
609
+ }
610
+ return 0;
611
+ } finally {
612
+ await rm(temporaryRoot, { recursive: true, force: true });
613
+ }
614
+ }
615
+
616
+ async function installDnfDockerEngine({ id, stdin, stdout, stderr, execute }) {
617
+ const repositoryUrl = `https://download.docker.com/linux/${id}/docker-ce.repo`;
618
+ const repositoryArgs = id === "fedora"
619
+ ? ["dnf", "config-manager", "addrepo", "--from-repofile", repositoryUrl]
620
+ : ["dnf", "config-manager", "--add-repo", repositoryUrl];
621
+ const pluginCode = await execute(
622
+ { command: "sudo", args: ["dnf", "-y", "install", "dnf-plugins-core"] },
623
+ { stdin, stdout, stderr }
624
+ );
625
+ if (pluginCode !== 0) {
626
+ return pluginCode;
627
+ }
628
+
629
+ let repositoryCode = await execute(
630
+ { command: "sudo", args: repositoryArgs },
631
+ { stdin, stdout, stderr }
632
+ );
633
+ if (repositoryCode !== 0 && id === "fedora") {
634
+ stdout.write("The DNF5 repository command was unavailable; retrying with DNF4 syntax...\n");
635
+ repositoryCode = await execute(
636
+ {
637
+ command: "sudo",
638
+ args: ["dnf", "config-manager", "--add-repo", repositoryUrl]
639
+ },
640
+ { stdin, stdout, stderr }
641
+ );
642
+ }
643
+ if (repositoryCode !== 0) {
644
+ return repositoryCode;
645
+ }
646
+
647
+ return execute(
648
+ {
649
+ command: "sudo",
650
+ args: [
651
+ "dnf",
652
+ "-y",
653
+ "install",
654
+ "docker-ce",
655
+ "docker-ce-cli",
656
+ "containerd.io",
657
+ "docker-buildx-plugin",
658
+ "docker-compose-plugin"
659
+ ]
660
+ },
661
+ { stdin, stdout, stderr }
662
+ );
663
+ }
664
+
665
+ async function readLinuxOsRelease() {
666
+ const content = await readFile("/etc/os-release", "utf8");
667
+ const values = {};
668
+ for (const line of content.split(/\r?\n/)) {
669
+ const match = /^([A-Z_]+)=(.*)$/.exec(line);
670
+ if (!match) continue;
671
+ const raw = match[2].trim();
672
+ values[match[1]] = raw.replace(/^(['"])(.*)\1$/, "$2");
673
+ }
674
+ return values;
675
+ }
676
+
677
+ function linuxReentryCommand({ requestedProfile = "auto", noOpen = false } = {}) {
678
+ if (!["auto", "light", "standard"].includes(requestedProfile)) {
679
+ throw new Error("Invalid SpaceApp profile for Docker group re-entry.");
680
+ }
681
+ const entrypoint = fileURLToPath(new URL("../bin/spaceapp.mjs", import.meta.url));
682
+ return [
683
+ "/usr/bin/env",
684
+ "SPACEAPP_PREREQUISITES_BOOTSTRAPPED=1",
685
+ process.execPath,
686
+ entrypoint,
687
+ "install",
688
+ "--profile",
689
+ requestedProfile,
690
+ ...(noOpen ? ["--no-open"] : [])
691
+ ].map(shellQuote).join(" ");
692
+ }
693
+
694
+ function shellQuote(value) {
695
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
696
+ }
697
+
698
+ async function waitForDocker({ execute, sleep }) {
699
+ for (let attempt = 0; attempt < 90; attempt += 1) {
700
+ if (await dockerIsReady(execute)) {
701
+ return true;
702
+ }
703
+ await sleep(2_000);
704
+ }
705
+ return false;
706
+ }
707
+
708
+ function windowsDockerPaths(env) {
709
+ const localAppData = env.LOCALAPPDATA;
710
+ const programFiles = env.ProgramFiles || env.PROGRAMFILES;
711
+ return {
712
+ applications: [
713
+ localAppData && win32.join(localAppData, "Programs", "DockerDesktop", "Docker Desktop.exe"),
714
+ programFiles && win32.join(programFiles, "Docker", "Docker", "Docker Desktop.exe")
715
+ ].filter(Boolean),
716
+ cliDirectories: [
717
+ localAppData && win32.join(localAppData, "Programs", "DockerDesktop", "resources", "bin"),
718
+ programFiles && win32.join(programFiles, "Docker", "Docker", "resources", "bin")
719
+ ].filter(Boolean)
720
+ };
721
+ }
722
+
723
+ async function firstExisting(paths, pathExists) {
724
+ for (const path of paths) {
725
+ if (await pathExists(path)) {
726
+ return path;
727
+ }
728
+ }
729
+ return null;
730
+ }
731
+
732
+ function prependPath(env, directory) {
733
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") || "PATH";
734
+ const entries = String(env[pathKey] || "").split(delimiter).filter(Boolean);
735
+ if (!entries.includes(directory)) {
736
+ env[pathKey] = [directory, ...entries].join(delimiter);
737
+ }
738
+ }
739
+
740
+ async function fileExists(path) {
741
+ try {
742
+ await access(path);
743
+ return true;
744
+ } catch {
745
+ return false;
746
+ }
747
+ }
748
+
749
+ export async function downloadFile(url, target, {
750
+ timeoutMs = DOWNLOAD_TIMEOUT_MS
751
+ } = {}) {
752
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
753
+ throw new Error("Docker download timeout must be a positive number.");
754
+ }
755
+ const controller = new AbortController();
756
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
757
+ try {
758
+ const response = await fetch(url, {
759
+ redirect: "follow",
760
+ signal: controller.signal
761
+ });
762
+ if (!response.ok || !response.body) {
763
+ throw new Error(`Docker download failed with HTTP ${response.status}.`);
764
+ }
765
+ await pipeline(
766
+ Readable.fromWeb(response.body),
767
+ createWriteStream(target, { flags: "wx", mode: 0o600 }),
768
+ { signal: controller.signal }
769
+ );
770
+ } catch (error) {
771
+ if (controller.signal.aborted) {
772
+ throw new Error(
773
+ `Docker download timed out after ${Math.ceil(timeoutMs / 1_000)} seconds.`,
774
+ { cause: error }
775
+ );
776
+ }
777
+ throw error;
778
+ } finally {
779
+ clearTimeout(timeout);
780
+ }
781
+ }
782
+
783
+ function launchDetachedCommand(spec) {
784
+ return new Promise((resolve) => {
785
+ const options = {
786
+ detached: true,
787
+ env: spec.env ? { ...process.env, ...spec.env } : process.env,
788
+ shell: false,
789
+ stdio: "ignore"
790
+ };
791
+ let child;
792
+ try {
793
+ if (spec.operation === "start-docker-desktop") {
794
+ child = spawn("powershell.exe", windowsPowerShellArgs(spec.operation), options);
795
+ } else if (spec.operation === "open-docker-desktop") {
796
+ child = spawn("open", ["-a", "Docker"], options);
797
+ } else {
798
+ resolve(1);
799
+ return;
800
+ }
801
+ } catch {
802
+ resolve(1);
803
+ return;
804
+ }
805
+ child.once("error", () => resolve(1));
806
+ child.once("spawn", () => {
807
+ child.unref();
808
+ resolve(0);
809
+ });
810
+ });
811
+ }
812
+
813
+ function wait(milliseconds) {
814
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
815
+ }
@@ -3,6 +3,8 @@ services:
3
3
  image: ghcr.io/oll4com/spaceapp-core:${SPACEAPP_IMAGE_TAG}
4
4
  restart: unless-stopped
5
5
  init: true
6
+ security_opt:
7
+ - no-new-privileges:true
6
8
  mem_limit: "${SPACEAPP_CORE_MEMORY_LIMIT}"
7
9
  cpus: "${SPACEAPP_CORE_CPU_LIMIT}"
8
10
  environment:
@@ -70,6 +72,8 @@ services:
70
72
  image: ghcr.io/oll4com/spaceapp-cli:${SPACEAPP_IMAGE_TAG}
71
73
  restart: unless-stopped
72
74
  init: true
75
+ security_opt:
76
+ - no-new-privileges:true
73
77
  mem_limit: "${SPACEAPP_CLI_MEMORY_LIMIT}"
74
78
  cpus: "${SPACEAPP_CLI_CPU_LIMIT}"
75
79
  environment:
@@ -90,6 +94,8 @@ services:
90
94
  profiles: ["standard"]
91
95
  restart: unless-stopped
92
96
  init: true
97
+ security_opt:
98
+ - no-new-privileges:true
93
99
  mem_limit: "${SPACEAPP_BROWSER_MEMORY_LIMIT}"
94
100
  cpus: "${SPACEAPP_BROWSER_CPU_LIMIT}"
95
101
  environment: