run-spaceapp 0.1.3 → 0.1.5

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