run-spaceapp 0.1.7 → 0.1.9

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.9",
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": "bca19d7f71db3bfb097c4c00146b4f51957494d9"
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,15 +107,28 @@ 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");
105
- return openBrowser(`http://${config.bindHost}:${config.port}`, platform, execute, { stdin, stdout, stderr });
126
+ const url = `http://${config.bindHost}:${config.port}`;
127
+ const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
128
+ if (openCode !== 0) {
129
+ stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
130
+ }
131
+ return 0;
106
132
  }
107
133
  if (command === "doctor") {
108
134
  assertNoArgs(args, "doctor");
@@ -112,16 +138,16 @@ export async function run(argv, {
112
138
  return workspaceCommand(args, { root, config, stdout });
113
139
  }
114
140
  if (command === "credentials") {
115
- return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute });
141
+ return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
116
142
  }
117
143
  if (command === "provider") {
118
- return providerCommand(args, { root, config, stdin, stdout, stderr, execute });
144
+ return providerCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
119
145
  }
120
146
  if (command === "owner") {
121
- return ownerCommand(args, { root, config, stdin, stdout, stderr, execute });
147
+ return ownerCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
122
148
  }
123
149
  if (command === "update") {
124
- return updateCommand(args, { root, config, version, stdin, stdout, stderr, execute });
150
+ return updateCommand(args, { root, config, version, stdin, stdout, stderr, execute: runtimeExecute });
125
151
  }
126
152
  if (command === "rollback") {
127
153
  assertNoArgs(args, "rollback");
@@ -134,12 +160,12 @@ export async function run(argv, {
134
160
  previousVersion: config.version
135
161
  };
136
162
  await writeRuntimeFiles(root, rollback);
137
- const pullCode = await execute(composeCommand("pull", root, { profile: rollback.profile }), { stdin, stdout, stderr });
163
+ const pullCode = await runtimeExecute(composeCommand("pull", root, { profile: rollback.profile }), { stdin, stdout, stderr });
138
164
  if (pullCode !== 0) {
139
165
  await writeRuntimeFiles(root, config);
140
166
  return pullCode;
141
167
  }
142
- const upCode = await execute(composeCommand("up", root, { profile: rollback.profile }), { stdin, stdout, stderr });
168
+ const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile }), { stdin, stdout, stderr });
143
169
  if (upCode !== 0) {
144
170
  await writeRuntimeFiles(root, config);
145
171
  return upCode;
@@ -150,7 +176,7 @@ export async function run(argv, {
150
176
  }
151
177
  if (command === "backup") {
152
178
  assertNoArgs(args, command);
153
- return execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
179
+ return runtimeExecute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
154
180
  }
155
181
  if (command === "restore") {
156
182
  assertNoArgs(args, command);
@@ -164,22 +190,33 @@ export async function run(argv, {
164
190
  throw new Error("Restore cancelled.");
165
191
  }
166
192
  const backupId = await selectLatestBackupId(root);
167
- const backupCode = await execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
193
+ const backupCode = await runtimeExecute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
168
194
  if (backupCode !== 0) return backupCode;
169
- const stopCode = await execute(composeCommand("stopForRestore", root, { profile: config.profile }), { stdin, stdout, stderr });
195
+ const stopCode = await runtimeExecute(composeCommand("stopForRestore", root, { profile: config.profile }), { stdin, stdout, stderr });
170
196
  if (stopCode !== 0) return stopCode;
171
- const restoreCode = await execute(
197
+ const restoreCode = await runtimeExecute(
172
198
  composeCommand("restore", root, { backupId, profile: config.profile }),
173
199
  { stdin, stdout, stderr }
174
200
  );
175
201
  if (restoreCode !== 0) return restoreCode;
176
- return execute(composeCommand("up", root, { profile: config.profile }), { stdin, stdout, stderr });
202
+ return runtimeExecute(composeCommand("up", root, { profile: config.profile }), { stdin, stdout, stderr });
177
203
  }
178
204
  if (command === "uninstall") {
179
205
  if (args.length === 0) {
180
- const code = await execute(composeCommand("down", root, { profile: config.profile }), { stdin, stdout, stderr });
206
+ stdout.write("Stopping and removing SpaceApp containers and network...\n");
207
+ const code = await runtimeExecute(
208
+ composeCommand("down", root, { profile: config.profile }),
209
+ { stdin, stdout, stderr }
210
+ );
181
211
  if (code === 0) {
182
- stdout.write(`Containers removed. Data and configuration remain at ${root}.\n`);
212
+ stdout.write("SpaceApp runtime removed successfully. It is safe to run this command again.\n");
213
+ stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`);
214
+ stdout.write("Docker volumes are retained. The global SpaceApp CLI remains installed.\n");
215
+ stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n");
216
+ } else {
217
+ stderr.write(`Uninstall could not remove the runtime (Docker exit ${code}).\n`);
218
+ stdout.write(`Data, configuration, secrets, and backups remain at ${root}.\n`);
219
+ stdout.write("The global SpaceApp CLI remains installed.\n");
183
220
  }
184
221
  return code;
185
222
  }
@@ -188,7 +225,21 @@ export async function run(argv, {
188
225
  if (confirmation !== "DELETE") {
189
226
  throw new Error("Purge cancelled.");
190
227
  }
191
- return execute(composeCommand("purge", root, { profile: config.profile }), { stdin, stdout, stderr });
228
+ stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n");
229
+ const code = await runtimeExecute(
230
+ composeCommand("purge", root, { profile: config.profile }),
231
+ { stdin, stdout, stderr }
232
+ );
233
+ if (code === 0) {
234
+ stdout.write("SpaceApp runtime and Docker volumes removed successfully.\n");
235
+ stdout.write(`Host configuration and backups remain at ${root} for manual review.\n`);
236
+ stdout.write("The global SpaceApp CLI remains installed.\n");
237
+ stdout.write("To remove only the global CLI, run: npm uninstall -g run-spaceapp\n");
238
+ } else {
239
+ stderr.write(`SpaceApp Docker volume purge failed (Docker exit ${code}).\n`);
240
+ stdout.write(`Host files and the global SpaceApp CLI remain at ${root}.\n`);
241
+ }
242
+ return code;
192
243
  }
193
244
  throw new Error("Usage: spaceapp uninstall [--purge-data]");
194
245
  }
@@ -207,7 +258,10 @@ async function installCommand(args, {
207
258
  stderr,
208
259
  execute,
209
260
  inspectResources,
210
- ensureDocker
261
+ ensureDocker,
262
+ request,
263
+ sleep,
264
+ persistSetupToken
211
265
  }) {
212
266
  const { requestedProfile, noOpen } = parseInstallArgs(args);
213
267
  const resources = await inspectResources(root);
@@ -218,11 +272,6 @@ async function installCommand(args, {
218
272
  `Selected profile: ${profile} (${formatGigabytes(resources.totalMemoryBytes)} GB system memory detected).\n`
219
273
  );
220
274
  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
275
  if (installResourceChecks(resources).some((check) => !check.ok)) {
227
276
  const doctorCode = await doctor({
228
277
  root,
@@ -270,13 +319,85 @@ async function installCommand(args, {
270
319
  stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
271
320
  return doctorCode;
272
321
  }
273
- const pullCode = await execute(composeCommand("pull", root, { profile }), { stdin, stdout, stderr });
322
+ const pullCode = await executeWithDockerDiagnostics(
323
+ execute,
324
+ composeCommand("pull", root, { profile }),
325
+ { stdin, stdout, stderr },
326
+ { platform, stderr }
327
+ );
274
328
  if (pullCode !== 0) return pullCode;
275
- const upCode = await execute(composeCommand("up", root, { profile }), { stdin, stdout, stderr });
329
+ const upCode = await executeWithDockerDiagnostics(
330
+ execute,
331
+ composeCommand("up", root, { profile }),
332
+ { stdin, stdout, stderr },
333
+ { platform, stderr }
334
+ );
276
335
  if (upCode !== 0) return upCode;
277
336
 
278
337
  const url = `http://${result.config.bindHost}:${result.config.port}`;
279
- stdout.write(`SpaceApp is running at ${url}\n`);
338
+ stdout.write("Waiting for SpaceApp services to become ready...\n");
339
+ const ready = await waitForApplicationReady({
340
+ url,
341
+ request,
342
+ sleep
343
+ });
344
+ if (!ready) {
345
+ stderr.write(
346
+ "SpaceApp containers started, but the application did not become ready within 3 minutes.\n" +
347
+ 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
348
+ );
349
+ return 1;
350
+ }
351
+
352
+ let setupStatus;
353
+ try {
354
+ setupStatus = await requestSetupStatus({ url, request });
355
+ } catch (error) {
356
+ stderr.write(
357
+ `SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
358
+ 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
359
+ );
360
+ return 1;
361
+ }
362
+
363
+ let setupToken = null;
364
+ if (setupStatus.setupRequired) {
365
+ setupToken = randomBytes(32).toString("base64url");
366
+ const rotateCode = await executeWithDockerDiagnostics(
367
+ execute,
368
+ composeCommand("rotateOwnerSetupToken", root, { profile }),
369
+ {
370
+ stdin,
371
+ stdout,
372
+ stderr,
373
+ input: `${setupToken}\n`
374
+ },
375
+ { platform, stderr }
376
+ );
377
+ if (rotateCode !== 0) {
378
+ stderr.write(
379
+ 'SpaceApp is ready, but a fresh owner setup token could not be created. Run "spaceapp owner rotate-setup-token".\n'
380
+ );
381
+ return rotateCode;
382
+ }
383
+ try {
384
+ await persistSetupToken(root, setupToken);
385
+ } catch {
386
+ stderr.write(
387
+ "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
388
+ 'Run "spaceapp owner rotate-setup-token" to obtain a usable token.\n'
389
+ );
390
+ return 1;
391
+ }
392
+ }
393
+
394
+ stdout.write(`SpaceApp is ready at ${url}\n`);
395
+ if (setupToken) {
396
+ stdout.write(`One-time setup token: ${setupToken}\n`);
397
+ stdout.write('Paste it into the "One-time setup token" field in the page that opens.\n');
398
+ stdout.write("It expires in 15 minutes and stops working after the first owner is created.\n");
399
+ stdout.write("If it expires, run: spaceapp owner rotate-setup-token\n");
400
+ }
280
401
  stdout.write('Next: add CLI credentials with "spaceapp credentials set <provider>".\n');
281
402
  if (noOpen) return 0;
282
403
  const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
@@ -464,27 +585,140 @@ async function doctor({
464
585
  { name: "Configuration", ok: true, detail: root },
465
586
  ...installResourceChecks(detectedResources)
466
587
  ];
467
- let dockerMissing = false;
588
+ const dockerResults = [];
468
589
  for (const probe of [
469
- { name: "Docker", command: "docker", args: ["--version"] },
590
+ { name: "Docker CLI", command: "docker", args: ["--version"] },
470
591
  { name: "Docker Compose", command: "docker", args: ["compose", "version"] },
471
592
  { name: "Docker Engine", command: "docker", args: ["info"] }
472
593
  ]) {
473
594
  const code = dockerReady
474
595
  ? 0
475
596
  : 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
- }
597
+ dockerResults.push({ ...probe, code });
598
+ }
599
+ const [dockerCli, dockerCompose, dockerEngine] = dockerResults;
600
+ checks.push({
601
+ name: dockerCli.name,
602
+ ok: dockerCli.code === 0,
603
+ detail: dockerCli.code === 0
604
+ ? "available"
605
+ : dockerCli.code === 127
606
+ ? "not found on PATH"
607
+ : `unavailable (exit ${dockerCli.code})`
608
+ });
609
+ checks.push({
610
+ name: dockerCompose.name,
611
+ ok: dockerCompose.code === 0,
612
+ detail: dockerCompose.code === 0
613
+ ? "available"
614
+ : dockerCli.code !== 0
615
+ ? "not available because Docker CLI is missing"
616
+ : `plugin unavailable (exit ${dockerCompose.code})`
617
+ });
618
+ checks.push({
619
+ name: dockerEngine.name,
620
+ ok: dockerEngine.code === 0,
621
+ detail: dockerEngine.code === 0
622
+ ? "available"
623
+ : dockerCli.code === 0
624
+ ? "installed but not running or inaccessible"
625
+ : "not reachable because Docker CLI is missing"
626
+ });
479
627
  for (const check of checks) {
480
628
  (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`);
481
629
  }
482
- if (dockerMissing) {
630
+ if (dockerCli.code !== 0 || dockerCompose.code !== 0) {
483
631
  stderr.write(`${dockerInstallHelp(platform)}\n`);
632
+ } else if (dockerEngine.code !== 0) {
633
+ stderr.write(`${dockerEngineHelp(platform)}\n`);
484
634
  }
485
635
  return checks.every((check) => check.ok) ? 0 : 1;
486
636
  }
487
637
 
638
+ async function waitForApplicationReady({
639
+ url,
640
+ request,
641
+ sleep,
642
+ maxAttempts = APPLICATION_READY_MAX_ATTEMPTS
643
+ }) {
644
+ if (typeof request !== "function") {
645
+ throw new Error("SpaceApp readiness requires a Fetch-compatible request function.");
646
+ }
647
+ const controller = new AbortController();
648
+ const timeout = setTimeout(() => controller.abort(), APPLICATION_READY_WAIT_MS);
649
+ try {
650
+ for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
651
+ try {
652
+ const response = await request(`${url}/readyz`, {
653
+ method: "GET",
654
+ headers: { accept: "application/json" },
655
+ redirect: "error",
656
+ signal: controller.signal
657
+ });
658
+ if (response?.ok) {
659
+ const payload = await response.json();
660
+ if (payload?.ok === true) {
661
+ return true;
662
+ }
663
+ }
664
+ } catch {
665
+ if (controller.signal.aborted) {
666
+ return false;
667
+ }
668
+ }
669
+ if (attempt < maxAttempts && !controller.signal.aborted) {
670
+ await sleep(APPLICATION_READY_POLL_MS);
671
+ }
672
+ }
673
+ return false;
674
+ } finally {
675
+ clearTimeout(timeout);
676
+ }
677
+ }
678
+
679
+ async function requestSetupStatus({ url, request }) {
680
+ const controller = new AbortController();
681
+ const timeout = setTimeout(() => controller.abort(), SETUP_STATUS_TIMEOUT_MS);
682
+ try {
683
+ const response = await request(`${url}/api/setup/status`, {
684
+ method: "GET",
685
+ headers: { accept: "application/json" },
686
+ redirect: "error",
687
+ signal: controller.signal
688
+ });
689
+ if (!response?.ok) {
690
+ throw new Error(`HTTP ${response?.status ?? "error"}`);
691
+ }
692
+ const payload = await response.json();
693
+ if (
694
+ !payload ||
695
+ typeof payload !== "object" ||
696
+ typeof payload.setupRequired !== "boolean" ||
697
+ (payload.expiresAt !== null && typeof payload.expiresAt !== "string")
698
+ ) {
699
+ throw new Error("invalid setup status response");
700
+ }
701
+ return payload;
702
+ } catch (error) {
703
+ if (controller.signal.aborted) {
704
+ throw new Error("setup status request timed out", { cause: error });
705
+ }
706
+ throw error;
707
+ } finally {
708
+ clearTimeout(timeout);
709
+ }
710
+ }
711
+
712
+ async function executeWithDockerDiagnostics(execute, spec, io, { platform, stderr }) {
713
+ const code = await execute(spec, io);
714
+ if (spec.command === "docker" && code === 127) {
715
+ stderr.write(
716
+ `SpaceApp could not find the Docker CLI. ${dockerInstallHelp(platform)}\n`
717
+ );
718
+ }
719
+ return code;
720
+ }
721
+
488
722
  export async function readSecret(stdin, stdout, prompt, { mask = true } = {}) {
489
723
  stdout.write(prompt);
490
724
  if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
@@ -621,6 +855,30 @@ function assertNoArgs(args, command) {
621
855
  }
622
856
  }
623
857
 
858
+ function commandNeedsRuntimeFiles(command, args) {
859
+ if ([
860
+ "up",
861
+ "down",
862
+ "status",
863
+ "logs",
864
+ "backup",
865
+ "restore",
866
+ "uninstall"
867
+ ].includes(command)) {
868
+ return true;
869
+ }
870
+ if (command === "credentials") {
871
+ return args[0] === "set" || args[0] === "remove";
872
+ }
873
+ if (command === "provider") {
874
+ return args[0] === "install";
875
+ }
876
+ if (command === "owner") {
877
+ return args[0] === "reset-password" || args[0] === "rotate-setup-token";
878
+ }
879
+ return false;
880
+ }
881
+
624
882
  function dockerInstallHelp(platform) {
625
883
  if (platform === "win32") {
626
884
  return 'Run "spaceapp install" to install and start signed Docker Desktop with WSL2 automatically.';
@@ -631,10 +889,21 @@ function dockerInstallHelp(platform) {
631
889
  return 'Run "spaceapp install" to install and start Docker Engine and Compose automatically on supported Linux distributions.';
632
890
  }
633
891
 
892
+ function dockerEngineHelp(platform) {
893
+ if (platform === "win32" || platform === "darwin") {
894
+ return 'Open Docker Desktop, complete any first-run prompt, then run "spaceapp doctor" again.';
895
+ }
896
+ return 'Start Docker Engine, verify the current user can access it, then run "spaceapp doctor" again.';
897
+ }
898
+
634
899
  function formatGigabytes(bytes) {
635
900
  return Math.floor((bytes / 1024 ** 3) * 10) / 10;
636
901
  }
637
902
 
903
+ function wait(milliseconds) {
904
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
905
+ }
906
+
638
907
  async function packageVersion() {
639
908
  const packageJson = new URL("../package.json", import.meta.url);
640
909
  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