run-spaceapp 0.1.6 → 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
@@ -28,8 +28,11 @@ images. Windows prefers Windows Package Manager's hash-pinned Docker Desktop
28
28
  manifest and retains a signed direct-download fallback. Docker Desktop license
29
29
  acceptance and Linux `docker` group membership require confirmation inside the
30
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.
31
+ Desktop. On Docker Desktop's first launch, select **Skip** in the top-right of
32
+ the **Welcome to Docker** window (or sign in), accept any remaining Docker
33
+ prompt, and keep the terminal open. SpaceApp waits for up to ten minutes and
34
+ continues automatically when Docker is ready. If Windows requests one restart,
35
+ rerun `spaceapp install` afterward and installation continues safely.
33
36
 
34
37
  The launcher selects the `light` profile below 12 GiB RAM and the `standard`
35
38
  profile otherwise; override this with `--profile light` or
@@ -64,3 +67,15 @@ Run `spaceapp help` for the complete command list. Full documentation:
64
67
  - [CLI providers](https://github.com/oll4com/spaceapp/blob/main/docs/cli-providers.md)
65
68
  - [Operations](https://github.com/oll4com/spaceapp/blob/main/docs/operations.md)
66
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.6",
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": "81bf4f16666f75cec5b499df28115df3c28e36b8"
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,16 @@ 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 });
283
+ stdout.write(
284
+ "Opening Docker Desktop for its first-run setup.\n" +
285
+ "If Docker shows \"Welcome to Docker\", select \"Skip\" in the top-right (or sign in), and accept any remaining Docker prompt.\n" +
286
+ "Keep this terminal open; SpaceApp will continue automatically when Docker is ready (up to 10 minutes).\n"
287
+ );
288
+ await execute(
289
+ { command: "docker", args: ["desktop", "start", "--detach"] },
290
+ { stdin, stdout: null, stderr: null }
291
+ );
265
292
  const launchCode = await launch({
266
293
  operation: "start-docker-desktop",
267
294
  env: {
@@ -272,12 +299,12 @@ async function ensureWindowsDocker({
272
299
  stderr.write("Docker Desktop is installed, but SpaceApp could not start it.\n");
273
300
  return { code: launchCode, reexecuted: false };
274
301
  }
275
- if (await waitForDocker({ execute, sleep })) {
302
+ if (await waitForDocker({ execute, sleep, attempts: 300 })) {
276
303
  stdout.write("Docker Desktop is ready.\n");
277
304
  return { code: 0, reexecuted: false };
278
305
  }
279
306
  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"
307
+ "Docker Desktop did not become ready after 10 minutes. Open Docker Desktop and complete its first-run setup (select \"Skip\" on \"Welcome to Docker\" or sign in), restart Windows if requested, then run \"spaceapp install\" again.\n"
281
308
  );
282
309
  return { code: 1, reexecuted: false };
283
310
  }
@@ -374,9 +401,7 @@ async function ensureMacDocker({
374
401
  stdout.write("Docker Desktop is installed but is not running. Starting it now...\n");
375
402
  }
376
403
 
377
- if (await pathExists(cliDirectory)) {
378
- prependPath(env, cliDirectory);
379
- }
404
+ await prepareDockerCliPath({ platform: "darwin", env, pathExists });
380
405
  const launchCode = await launch({ operation: "open-docker-desktop" });
381
406
  if (launchCode !== 0) {
382
407
  stderr.write("Docker Desktop is installed, but SpaceApp could not start it.\n");
@@ -695,12 +720,14 @@ function shellQuote(value) {
695
720
  return `'${String(value).replaceAll("'", "'\\''")}'`;
696
721
  }
697
722
 
698
- async function waitForDocker({ execute, sleep }) {
699
- for (let attempt = 0; attempt < 90; attempt += 1) {
723
+ async function waitForDocker({ execute, sleep, attempts = 90 }) {
724
+ for (let attempt = 0; attempt <= attempts; attempt += 1) {
700
725
  if (await dockerIsReady(execute)) {
701
726
  return true;
702
727
  }
703
- await sleep(2_000);
728
+ if (attempt < attempts) {
729
+ await sleep(2_000);
730
+ }
704
731
  }
705
732
  return false;
706
733
  }
@@ -729,11 +756,12 @@ async function firstExisting(paths, pathExists) {
729
756
  return null;
730
757
  }
731
758
 
732
- function prependPath(env, directory) {
759
+ function prependPath(env, directory, platform = process.platform) {
733
760
  const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") || "PATH";
734
- 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);
735
763
  if (!entries.includes(directory)) {
736
- env[pathKey] = [directory, ...entries].join(delimiter);
764
+ env[pathKey] = [directory, ...entries].join(pathDelimiter);
737
765
  }
738
766
  }
739
767