run-spaceapp 0.1.7 → 0.1.8

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
@@ -67,3 +67,15 @@ Run `spaceapp help` for the complete command list. Full documentation:
67
67
  - [CLI providers](https://github.com/oll4com/spaceapp/blob/main/docs/cli-providers.md)
68
68
  - [Operations](https://github.com/oll4com/spaceapp/blob/main/docs/operations.md)
69
69
  - [Security model](https://github.com/oll4com/spaceapp/blob/main/docs/security-model.md)
70
+
71
+ After the stack passes readiness checks, `spaceapp install` prints the fresh
72
+ 15-minute token to paste into the browser's **One-time setup token** field. If
73
+ it expires before the owner is created, run:
74
+
75
+ ```bash
76
+ spaceapp owner rotate-setup-token
77
+ ```
78
+
79
+ `spaceapp uninstall` removes the runtime while retaining data by default and
80
+ prints the separate `npm uninstall -g run-spaceapp` command for removing the
81
+ global launcher.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-spaceapp",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -45,5 +45,5 @@
45
45
  "access": "public",
46
46
  "provenance": true
47
47
  },
48
- "gitHead": "3f5612291d2aea84f1a763c8b094ec0f42208fd1"
48
+ "gitHead": "9415b97036843aa6186c02eb18826221138c4a23"
49
49
  }
package/src/cli.mjs CHANGED
@@ -22,9 +22,14 @@ import {
22
22
  } from "./index.mjs";
23
23
  import {
24
24
  ensureDockerAvailable,
25
+ prepareDockerCliPath,
25
26
  windowsPowerShellArgs
26
27
  } from "./prerequisites.mjs";
27
28
 
29
+ const APPLICATION_READY_WAIT_MS = 3 * 60 * 1_000;
30
+ const APPLICATION_READY_POLL_MS = 2_000;
31
+ const APPLICATION_READY_MAX_ATTEMPTS = APPLICATION_READY_WAIT_MS / APPLICATION_READY_POLL_MS;
32
+ const SETUP_STATUS_TIMEOUT_MS = 10_000;
28
33
  const trustedCommands = new Set([
29
34
  "codesign",
30
35
  "docker",
@@ -52,8 +57,13 @@ export async function run(argv, {
52
57
  stdin = process.stdin,
53
58
  execute = executeCommand,
54
59
  inspectResources = inspectSystemResources,
55
- ensureDocker = ensureDockerAvailable
60
+ ensureDocker = ensureDockerAvailable,
61
+ prepareDockerPath = prepareDockerCliPath,
62
+ request = globalThis.fetch,
63
+ sleep = wait,
64
+ persistSetupToken = writeSetupToken
56
65
  } = {}) {
66
+ await prepareDockerPath({ platform, env });
57
67
  const [command = "help", ...args] = argv;
58
68
  const root = resolveSpaceAppHome({ env, platform });
59
69
  const version = await packageVersion();
@@ -78,7 +88,10 @@ export async function run(argv, {
78
88
  stderr,
79
89
  execute,
80
90
  inspectResources,
81
- ensureDocker
91
+ ensureDocker,
92
+ request,
93
+ sleep,
94
+ persistSetupToken
82
95
  });
83
96
  }
84
97
  if (command === "init") {
@@ -94,11 +107,19 @@ export async function run(argv, {
94
107
  }
95
108
 
96
109
  const config = await loadConfig(root);
97
- await writeRuntimeFiles(root, config);
110
+ if (commandNeedsRuntimeFiles(command, args)) {
111
+ await writeRuntimeFiles(root, config);
112
+ }
113
+ const runtimeExecute = (spec, io) => executeWithDockerDiagnostics(
114
+ execute,
115
+ spec,
116
+ io,
117
+ { platform, stderr }
118
+ );
98
119
 
99
120
  if (["up", "down", "status", "logs"].includes(command)) {
100
121
  assertNoArgs(args, command);
101
- return execute(composeCommand(command, root, { profile: config.profile }), { stdin, stdout, stderr });
122
+ return runtimeExecute(composeCommand(command, root, { profile: config.profile }), { stdin, stdout, stderr });
102
123
  }
103
124
  if (command === "open") {
104
125
  assertNoArgs(args, "open");
@@ -112,16 +133,16 @@ export async function run(argv, {
112
133
  return workspaceCommand(args, { root, config, stdout });
113
134
  }
114
135
  if (command === "credentials") {
115
- return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute });
136
+ return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
116
137
  }
117
138
  if (command === "provider") {
118
- return providerCommand(args, { root, config, stdin, stdout, stderr, execute });
139
+ return providerCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
119
140
  }
120
141
  if (command === "owner") {
121
- return ownerCommand(args, { root, config, stdin, stdout, stderr, execute });
142
+ return ownerCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
122
143
  }
123
144
  if (command === "update") {
124
- return updateCommand(args, { root, config, version, stdin, stdout, stderr, execute });
145
+ return updateCommand(args, { root, config, version, stdin, stdout, stderr, execute: runtimeExecute });
125
146
  }
126
147
  if (command === "rollback") {
127
148
  assertNoArgs(args, "rollback");
@@ -134,12 +155,12 @@ export async function run(argv, {
134
155
  previousVersion: config.version
135
156
  };
136
157
  await writeRuntimeFiles(root, rollback);
137
- const pullCode = await execute(composeCommand("pull", root, { profile: rollback.profile }), { stdin, stdout, stderr });
158
+ const pullCode = await runtimeExecute(composeCommand("pull", root, { profile: rollback.profile }), { stdin, stdout, stderr });
138
159
  if (pullCode !== 0) {
139
160
  await writeRuntimeFiles(root, config);
140
161
  return pullCode;
141
162
  }
142
- const upCode = await execute(composeCommand("up", root, { profile: rollback.profile }), { stdin, stdout, stderr });
163
+ const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile }), { stdin, stdout, stderr });
143
164
  if (upCode !== 0) {
144
165
  await writeRuntimeFiles(root, config);
145
166
  return upCode;
@@ -150,7 +171,7 @@ export async function run(argv, {
150
171
  }
151
172
  if (command === "backup") {
152
173
  assertNoArgs(args, command);
153
- return execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
174
+ return runtimeExecute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
154
175
  }
155
176
  if (command === "restore") {
156
177
  assertNoArgs(args, command);
@@ -164,22 +185,33 @@ export async function run(argv, {
164
185
  throw new Error("Restore cancelled.");
165
186
  }
166
187
  const backupId = await selectLatestBackupId(root);
167
- const backupCode = await execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
188
+ const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
168
189
  if (backupCode !== 0) return backupCode;
169
- const stopCode = await execute(composeCommand("stopForRestore", root, { profile: config.profile }), { stdin, stdout, stderr });
190
+ const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile }), { stdin, stdout, stderr });
170
191
  if (stopCode !== 0) return stopCode;
171
- const restoreCode = await execute(
192
+ const restoreCode = await runtimeExecute(
172
193
  composeCommand("restore", root, { backupId, profile: config.profile }),
173
194
  { stdin, stdout, stderr }
174
195
  );
175
196
  if (restoreCode !== 0) return restoreCode;
176
- return execute(composeCommand("up", root, { profile: config.profile }), { stdin, stdout, stderr });
197
+ return runtimeExecute(composeCommand("up", root, { profile: config.profile }), { stdin, stdout, stderr });
177
198
  }
178
199
  if (command === "uninstall") {
179
200
  if (args.length === 0) {
180
- const code = await execute(composeCommand("down", root, { profile: config.profile }), { stdin, stdout, stderr });
201
+ stdout.write("Stopping and removing SpaceApp containers and network...\n");
202
+ const code = await runtimeExecute(
203
+ composeCommand("down", root, { profile: config.profile }),
204
+ { stdin, stdout, stderr }
205
+ );
181
206
  if (code === 0) {
182
- stdout.write(`Containers removed. Data and configuration remain at ${root}.\n`);
207
+ stdout.write("SpaceApp runtime removed successfully. It is safe to run this command again.\n");
208
+ stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`);
209
+ stdout.write("Docker volumes are retained. The global SpaceApp CLI remains installed.\n");
210
+ stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n");
211
+ } else {
212
+ stderr.write(`Uninstall could not remove the runtime (Docker exit ${code}).\n`);
213
+ stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`);
214
+ stdout.write("The global SpaceApp CLI remains installed.\n");
183
215
  }
184
216
  return code;
185
217
  }
@@ -188,7 +220,21 @@ export async function run(argv, {
188
220
  if (confirmation !== "DELETE") {
189
221
  throw new Error("Purge cancelled.");
190
222
  }
191
- return execute(composeCommand("purge", root, { profile: config.profile }), { stdin, stdout, stderr });
223
+ stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n");
224
+ const code = await runtimeExecute(
225
+ composeCommand("purge", root, { profile: config.profile }),
226
+ { stdin, stdout, stderr }
227
+ );
228
+ if (code === 0) {
229
+ stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n");
230
+ stdout.write(`Host configuration and backups remain at ${root} for manual review.\n`);
231
+ stdout.write("The global SpaceApp CLI remains installed.\n");
232
+ stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n");
233
+ } else {
234
+ stderr.write(`SpaceApp Docker volume purge failed (Docker exit ${code}).\n`);
235
+ stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`);
236
+ }
237
+ return code;
192
238
  }
193
239
  throw new Error("Usage: spaceapp uninstall [--purge-data]");
194
240
  }
@@ -207,7 +253,10 @@ async function installCommand(args, {
207
253
  stderr,
208
254
  execute,
209
255
  inspectResources,
210
- ensureDocker
256
+ ensureDocker,
257
+ request,
258
+ sleep,
259
+ persistSetupToken
211
260
  }) {
212
261
  const { requestedProfile, noOpen } = parseInstallArgs(args);
213
262
  const resources = await inspectResources(root);
@@ -218,11 +267,6 @@ async function installCommand(args, {
218
267
  `Selected profile: ${profile} (${formatGigabytes(resources.totalMemoryBytes)} GB system memory detected).\n`
219
268
  );
220
269
  stdout.write(`SpaceApp installation root: ${root}\n`);
221
- if (result.setupToken) {
222
- stdout.write(`One-time setup token: ${result.setupToken}\n`);
223
- stdout.write("Store it temporarily; it expires after first owner setup.\n");
224
- }
225
-
226
270
  if (installResourceChecks(resources).some((check) => !check.ok)) {
227
271
  const doctorCode = await doctor({
228
272
  root,
@@ -270,13 +314,85 @@ async function installCommand(args, {
270
314
  stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
271
315
  return doctorCode;
272
316
  }
273
- const pullCode = await execute(composeCommand("pull", root, { profile }), { stdin, stdout, stderr });
317
+ const pullCode = await executeWithDockerDiagnostics(
318
+ execute,
319
+ composeCommand("pull", root, { profile }),
320
+ { stdin, stdout, stderr },
321
+ { platform, stderr }
322
+ );
274
323
  if (pullCode !== 0) return pullCode;
275
- const upCode = await execute(composeCommand("up", root, { profile }), { stdin, stdout, stderr });
324
+ const upCode = await executeWithDockerDiagnostics(
325
+ execute,
326
+ composeCommand("up", root, { profile }),
327
+ { stdin, stdout, stderr },
328
+ { platform, stderr }
329
+ );
276
330
  if (upCode !== 0) return upCode;
277
331
 
278
332
  const url = `http://${result.config.bindHost}:${result.config.port}`;
279
- stdout.write(`SpaceApp is running at ${url}\n`);
333
+ stdout.write("Waiting for SpaceApp services to become ready...\n");
334
+ const ready = await waitForApplicationReady({
335
+ url,
336
+ request,
337
+ sleep
338
+ });
339
+ if (!ready) {
340
+ stderr.write(
341
+ "SpaceApp containers started, but the application did not become ready within 3 minutes.\n" +
342
+ 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
343
+ );
344
+ return 1;
345
+ }
346
+
347
+ let setupStatus;
348
+ try {
349
+ setupStatus = await requestSetupStatus({ url, request });
350
+ } catch (error) {
351
+ stderr.write(
352
+ `SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
353
+ 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
354
+ );
355
+ return 1;
356
+ }
357
+
358
+ let setupToken = null;
359
+ if (setupStatus.setupRequired) {
360
+ setupToken = randomBytes(32).toString("base64url");
361
+ const rotateCode = await executeWithDockerDiagnostics(
362
+ execute,
363
+ composeCommand("rotateOwnerSetupToken", root, { profile }),
364
+ {
365
+ stdin,
366
+ stdout,
367
+ stderr,
368
+ input: `${setupToken}\n`
369
+ },
370
+ { platform, stderr }
371
+ );
372
+ if (rotateCode !== 0) {
373
+ stderr.write(
374
+ 'SpaceApp is ready, but a fresh owner setup token could not be created. Run "spaceapp owner rotate-setup-token".\n'
375
+ );
376
+ return rotateCode;
377
+ }
378
+ try {
379
+ await persistSetupToken(root, setupToken);
380
+ } catch {
381
+ stderr.write(
382
+ "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
383
+ 'Run "spaceapp owner rotate-setup-token" to obtain a usable token.\n'
384
+ );
385
+ return 1;
386
+ }
387
+ }
388
+
389
+ stdout.write(`SpaceApp is ready at ${url}\n`);
390
+ if (setupToken) {
391
+ stdout.write(`One-time setup token: ${setupToken}\n`);
392
+ stdout.write('Paste it into the "One-time setup token" field in the page that opens.\n');
393
+ stdout.write("It expires in 15 minutes and stops working after the first owner is created.\n");
394
+ stdout.write("If it expires, run: spaceapp owner rotate-setup-token\n");
395
+ }
280
396
  stdout.write('Next: add CLI credentials with "spaceapp credentials set <provider>".\n');
281
397
  if (noOpen) return 0;
282
398
  const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
@@ -464,27 +580,140 @@ async function doctor({
464
580
  { name: "Configuration", ok: true, detail: root },
465
581
  ...installResourceChecks(detectedResources)
466
582
  ];
467
- let dockerMissing = false;
583
+ const dockerResults = [];
468
584
  for (const probe of [
469
- { name: "Docker", command: "docker", args: ["--version"] },
585
+ { name: "Docker CLI", command: "docker", args: ["--version"] },
470
586
  { name: "Docker Compose", command: "docker", args: ["compose", "version"] },
471
587
  { name: "Docker Engine", command: "docker", args: ["info"] }
472
588
  ]) {
473
589
  const code = dockerReady
474
590
  ? 0
475
591
  : await execute(probe, { stdin, stdout: null, stderr: null });
476
- if (code !== 0) dockerMissing = true;
477
- checks.push({ name: probe.name, ok: code === 0, detail: code === 0 ? "available" : "missing" });
478
- }
592
+ dockerResults.push({ ...probe, code });
593
+ }
594
+ const [dockerCli, dockerCompose, dockerEngine] = dockerResults;
595
+ checks.push({
596
+ name: dockerCli.name,
597
+ ok: dockerCli.code === 0,
598
+ detail: dockerCli.code === 0
599
+ ? "available"
600
+ : dockerCli.code === 127
601
+ ? "not found on PATH"
602
+ : `unavailable (exit ${dockerCli.code})`
603
+ });
604
+ checks.push({
605
+ name: dockerCompose.name,
606
+ ok: dockerCompose.code === 0,
607
+ detail: dockerCompose.code === 0
608
+ ? "available"
609
+ : dockerCli.code !== 0
610
+ ? "not available because Docker CLI is missing"
611
+ : `plugin unavailable (exit ${dockerCompose.code})`
612
+ });
613
+ checks.push({
614
+ name: dockerEngine.name,
615
+ ok: dockerEngine.code === 0,
616
+ detail: dockerEngine.code === 0
617
+ ? "available"
618
+ : dockerCli.code === 0
619
+ ? "installed but not running or inaccessible"
620
+ : "not reachable because Docker CLI is missing"
621
+ });
479
622
  for (const check of checks) {
480
623
  (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`);
481
624
  }
482
- if (dockerMissing) {
625
+ if (dockerCli.code !== 0 || dockerCompose.code !== 0) {
483
626
  stderr.write(`${dockerInstallHelp(platform)}\n`);
627
+ } else if (dockerEngine.code !== 0) {
628
+ stderr.write(`${dockerEngineHelp(platform)}\n`);
484
629
  }
485
630
  return checks.every((check) => check.ok) ? 0 : 1;
486
631
  }
487
632
 
633
+ async function waitForApplicationReady({
634
+ url,
635
+ request,
636
+ sleep,
637
+ maxAttempts = APPLICATION_READY_MAX_ATTEMPTS
638
+ }) {
639
+ if (typeof request !== "function") {
640
+ throw new Error("SpaceApp readiness requires a Fetch-compatible request function.");
641
+ }
642
+ const controller = new AbortController();
643
+ const timeout = setTimeout(() => controller.abort(), APPLICATION_READY_WAIT_MS);
644
+ try {
645
+ for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
646
+ try {
647
+ const response = await request(`${url}/readyz`, {
648
+ method: "GET",
649
+ headers: { accept: "application/json" },
650
+ redirect: "error",
651
+ signal: controller.signal
652
+ });
653
+ if (response?.ok) {
654
+ const payload = await response.json();
655
+ if (payload?.ok === true) {
656
+ return true;
657
+ }
658
+ }
659
+ } catch {
660
+ if (controller.signal.aborted) {
661
+ return false;
662
+ }
663
+ }
664
+ if (attempt < maxAttempts && !controller.signal.aborted) {
665
+ await sleep(APPLICATION_READY_POLL_MS);
666
+ }
667
+ }
668
+ return false;
669
+ } finally {
670
+ clearTimeout(timeout);
671
+ }
672
+ }
673
+
674
+ async function requestSetupStatus({ url, request }) {
675
+ const controller = new AbortController();
676
+ const timeout = setTimeout(() => controller.abort(), SETUP_STATUS_TIMEOUT_MS);
677
+ try {
678
+ const response = await request(`${url}/api/setup/status`, {
679
+ method: "GET",
680
+ headers: { accept: "application/json" },
681
+ redirect: "error",
682
+ signal: controller.signal
683
+ });
684
+ if (!response?.ok) {
685
+ throw new Error(`HTTP ${response?.status ?? "error"}`);
686
+ }
687
+ const payload = await response.json();
688
+ if (
689
+ !payload ||
690
+ typeof payload !== "object" ||
691
+ typeof payload.setupRequired !== "boolean" ||
692
+ (payload.expiresAt !== null && typeof payload.expiresAt !== "string")
693
+ ) {
694
+ throw new Error("invalid setup status response");
695
+ }
696
+ return payload;
697
+ } catch (error) {
698
+ if (controller.signal.aborted) {
699
+ throw new Error("setup status request timed out", { cause: error });
700
+ }
701
+ throw error;
702
+ } finally {
703
+ clearTimeout(timeout);
704
+ }
705
+ }
706
+
707
+ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stderr }) {
708
+ const code = await execute(spec, io);
709
+ if (spec.command === "docker" && code === 127) {
710
+ stderr.write(
711
+ `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n`
712
+ );
713
+ }
714
+ return code;
715
+ }
716
+
488
717
  export async function readSecret(stdin, stdout, prompt, { mask = true } = {}) {
489
718
  stdout.write(prompt);
490
719
  if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
@@ -621,6 +850,30 @@ function assertNoArgs(args, command) {
621
850
  }
622
851
  }
623
852
 
853
+ function commandNeedsRuntimeFiles(command, args) {
854
+ if ([
855
+ "up",
856
+ "down",
857
+ "status",
858
+ "logs",
859
+ "backup",
860
+ "restore",
861
+ "uninstall"
862
+ ].includes(command)) {
863
+ return true;
864
+ }
865
+ if (command === "credentials") {
866
+ return args[0] === "set" || args[0] === "remove";
867
+ }
868
+ if (command === "provider") {
869
+ return args[0] === "install";
870
+ }
871
+ if (command === "owner") {
872
+ return args[0] === "reset-password" || args[0] === "rotate-setup-token";
873
+ }
874
+ return false;
875
+ }
876
+
624
877
  function dockerInstallHelp(platform) {
625
878
  if (platform === "win32") {
626
879
  return 'Run "spaceapp install" to install and start signed Docker Desktop with WSL2 automatically.';
@@ -631,10 +884,21 @@ function dockerInstallHelp(platform) {
631
884
  return 'Run "spaceapp install" to install and start Docker Engine and Compose automatically on supported Linux distributions.';
632
885
  }
633
886
 
887
+ function dockerEngineHelp(platform) {
888
+ if (platform === "win32" || platform === "darwin") {
889
+ return 'Open Docker Desktop, complete any first-run prompt, then run "spaceapp doctor" again.';
890
+ }
891
+ return 'Start Docker Engine, verify the current user can access it, then run "spaceapp doctor" again.';
892
+ }
893
+
634
894
  function formatGigabytes(bytes) {
635
895
  return Math.floor((bytes / 1024 ** 3) * 10) / 10;
636
896
  }
637
897
 
898
+ function wait(milliseconds) {
899
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
900
+ }
901
+
638
902
  async function packageVersion() {
639
903
  const packageJson = new URL("../package.json", import.meta.url);
640
904
  return JSON.parse(await readFile(packageJson, "utf8")).version;
@@ -1,7 +1,7 @@
1
1
  import { createWriteStream } from "node:fs";
2
2
  import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { delimiter, join, posix, win32 } from "node:path";
4
+ import { join, posix, win32 } from "node:path";
5
5
  import process from "node:process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { Readable } from "node:stream";
@@ -67,6 +67,27 @@ export function windowsPowerShellArgs(operation) {
67
67
  return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", scripts[operation]];
68
68
  }
69
69
 
70
+ export async function prepareDockerCliPath({
71
+ platform = process.platform,
72
+ env = process.env,
73
+ pathExists = fileExists
74
+ } = {}) {
75
+ let directories = [];
76
+ if (platform === "win32") {
77
+ directories = windowsDockerPaths(env).cliDirectories;
78
+ } else if (platform === "darwin") {
79
+ directories = ["/Applications/Docker.app/Contents/Resources/bin"];
80
+ } else {
81
+ return null;
82
+ }
83
+ const directory = await firstExisting(directories, pathExists);
84
+ if (!directory) {
85
+ return null;
86
+ }
87
+ prependPath(env, directory, platform);
88
+ return directory;
89
+ }
90
+
70
91
  export async function ensureDockerAvailable({
71
92
  platform = process.platform,
72
93
  arch = process.arch,
@@ -258,10 +279,7 @@ async function ensureWindowsDocker({
258
279
  stdout.write("Docker Desktop is installed but is not running. Starting it now...\n");
259
280
  }
260
281
 
261
- const cliDirectory = await firstExisting(paths.cliDirectories, pathExists);
262
- if (cliDirectory) {
263
- prependPath(env, cliDirectory);
264
- }
282
+ await prepareDockerCliPath({ platform: "win32", env, pathExists });
265
283
  stdout.write(
266
284
  "Opening Docker Desktop for its first-run setup.\n" +
267
285
  "If Docker shows \"Welcome to Docker\", select \"Skip\" in the top-right (or sign in), and accept any remaining Docker prompt.\n" +
@@ -383,9 +401,7 @@ async function ensureMacDocker({
383
401
  stdout.write("Docker Desktop is installed but is not running. Starting it now...\n");
384
402
  }
385
403
 
386
- if (await pathExists(cliDirectory)) {
387
- prependPath(env, cliDirectory);
388
- }
404
+ await prepareDockerCliPath({ platform: "darwin", env, pathExists });
389
405
  const launchCode = await launch({ operation: "open-docker-desktop" });
390
406
  if (launchCode !== 0) {
391
407
  stderr.write("Docker Desktop is installed, but SpaceApp could not start it.\n");
@@ -740,11 +756,12 @@ async function firstExisting(paths, pathExists) {
740
756
  return null;
741
757
  }
742
758
 
743
- function prependPath(env, directory) {
759
+ function prependPath(env, directory, platform = process.platform) {
744
760
  const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") || "PATH";
745
- const entries = String(env[pathKey] || "").split(delimiter).filter(Boolean);
761
+ const pathDelimiter = platform === "win32" ? win32.delimiter : posix.delimiter;
762
+ const entries = String(env[pathKey] || "").split(pathDelimiter).filter(Boolean);
746
763
  if (!entries.includes(directory)) {
747
- env[pathKey] = [directory, ...entries].join(delimiter);
764
+ env[pathKey] = [directory, ...entries].join(pathDelimiter);
748
765
  }
749
766
  }
750
767