run-spaceapp 0.1.11 → 0.1.13

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
@@ -5,7 +5,7 @@ Cross-platform Docker launcher for SpaceApp.
5
5
  Run the same command in Linux, macOS, or Windows 11 Command Prompt:
6
6
 
7
7
  ```bash
8
- npx --yes run-spaceapp install
8
+ npx --yes run-spaceapp@latest install
9
9
  ```
10
10
 
11
11
  To keep a global `spaceapp` command for later operations, install it
@@ -16,10 +16,12 @@ npm install -g run-spaceapp
16
16
  ```
17
17
 
18
18
  The launcher requires Node.js 20.11 or newer, 4 CPUs, 8 GB RAM, and 15 GiB free
19
- disk. If Docker is missing, `spaceapp install` automatically installs it from
20
- official Docker sources on Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and
21
- CentOS, starts it, and waits for Engine and Compose readiness before pulling
22
- images. Windows prefers Windows Package Manager's hash-pinned Docker Desktop
19
+ disk. Keep `@latest` in the command so npm resolves the current release instead
20
+ of matching an existing local launcher. If Docker is missing, the universal
21
+ install command automatically installs it from official Docker sources on
22
+ Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and CentOS, starts it, and waits
23
+ for Engine and Compose readiness before pulling images. Windows prefers
24
+ Windows Package Manager's hash-pinned Docker Desktop
23
25
  manifest and retains a signed direct-download fallback. Docker Desktop license
24
26
  acceptance and Linux `docker` group membership require confirmation inside the
25
27
  same command. On Windows, approve any UAC prompt required by WSL2 or Docker
@@ -33,9 +35,9 @@ resume and asks before scheduling it. Sign back in after Windows restarts; the
33
35
  same install continues automatically in Command Prompt without entering a
34
36
  second command.
35
37
 
36
- The launcher defaults to the `light` profile on every system. Select
37
- `--profile standard` explicitly when managed Chromium is required and the host
38
- has the recommended resources.
38
+ The launcher defaults to the `light` profile on every system. Run
39
+ `npx --yes run-spaceapp@latest install --profile standard` explicitly when
40
+ managed Chromium is required and the host has the recommended resources.
39
41
 
40
42
  Light mode retains every bundled CLI and the core data/workflow services while
41
43
  omitting managed Chromium. Resource limits are maximums, not immediate RAM or
@@ -57,24 +59,34 @@ Claude Code is not redistributed in SpaceApp images. Install the reviewed
57
59
  package as an explicit owner action with:
58
60
 
59
61
  ```bash
60
- spaceapp provider install claude
62
+ npx --yes run-spaceapp@latest provider install claude
61
63
  ```
62
64
 
63
- Run `spaceapp help` for the complete command list. Full documentation:
65
+ Run `npx --yes run-spaceapp@latest help` for the complete command list. Full
66
+ documentation:
64
67
 
65
68
  - [Getting started](https://github.com/oll4com/spaceapp/blob/main/docs/getting-started.md)
66
69
  - [CLI providers](https://github.com/oll4com/spaceapp/blob/main/docs/cli-providers.md)
67
70
  - [Operations](https://github.com/oll4com/spaceapp/blob/main/docs/operations.md)
68
71
  - [Security model](https://github.com/oll4com/spaceapp/blob/main/docs/security-model.md)
69
72
 
70
- After the stack passes readiness checks, `spaceapp install` prints the fresh
73
+ After the stack passes readiness checks, the install command prints the fresh
71
74
  15-minute token to paste into the browser's **One-time setup token** field. If
72
75
  it expires before the owner is created, run:
73
76
 
74
77
  ```bash
75
- spaceapp owner rotate-setup-token
78
+ npx --yes run-spaceapp@latest owner rotate-setup-token
76
79
  ```
77
80
 
78
- `spaceapp uninstall` removes the runtime while retaining data by default and
79
- prints the separate `npm uninstall -g run-spaceapp` command for removing the
80
- global launcher.
81
+ Common follow-up commands use the same reliable launcher prefix:
82
+
83
+ ```bash
84
+ npx --yes run-spaceapp@latest doctor
85
+ npx --yes run-spaceapp@latest status
86
+ npx --yes run-spaceapp@latest credentials list
87
+ npx --yes run-spaceapp@latest uninstall
88
+ ```
89
+
90
+ Uninstall retains data by default and prints the separate
91
+ `npm uninstall -g run-spaceapp` command for removing an optional global
92
+ launcher.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-spaceapp",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -46,5 +46,5 @@
46
46
  "access": "public",
47
47
  "provenance": true
48
48
  },
49
- "gitHead": "c53e6dd08441f3a517f21f7b0ca6f82ec36a1761"
49
+ "gitHead": "14a0f1117141c24c3436768c23e819f482eaed70"
50
50
  }
package/src/cli.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { readFile } from "node:fs/promises";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
4
6
  import process from "node:process";
5
7
  import {
6
8
  addWorkspace,
9
+ commitInstallation,
7
10
  composeCommand,
8
11
  credentialProviders,
9
12
  initializeInstallation,
@@ -12,6 +15,7 @@ import {
12
15
  loadConfig,
13
16
  removeCredential,
14
17
  removeWorkspace,
18
+ prepareInstallation,
15
19
  resolveInstallProfile,
16
20
  resolveSpaceAppHome,
17
21
  saveConfig,
@@ -30,10 +34,10 @@ const APPLICATION_READY_WAIT_MS = 3 * 60 * 1_000;
30
34
  const APPLICATION_READY_POLL_MS = 2_000;
31
35
  const APPLICATION_READY_MAX_ATTEMPTS = APPLICATION_READY_WAIT_MS / APPLICATION_READY_POLL_MS;
32
36
  const SETUP_STATUS_TIMEOUT_MS = 10_000;
37
+ const UNIVERSAL_COMMAND = "npx --yes run-spaceapp@latest";
33
38
  const trustedCommands = new Set([
34
39
  "codesign",
35
40
  "docker",
36
- "explorer.exe",
37
41
  "hdiutil",
38
42
  "open",
39
43
  "powershell.exe",
@@ -47,6 +51,7 @@ const trustedCommands = new Set([
47
51
  ]);
48
52
  const trustedEnvironmentNames = new Set([
49
53
  "SPACEAPP_DOCKER_INSTALLER_PATH",
54
+ "SPACEAPP_OPEN_URL",
50
55
  "SPACEAPP_RESUME_SCRIPT_PATH"
51
56
  ]);
52
57
 
@@ -104,7 +109,9 @@ export async function run(argv, {
104
109
  stdout.write(`One-time setup token: ${result.setupToken}\n`);
105
110
  stdout.write("Store it temporarily; it expires after first owner setup.\n");
106
111
  }
107
- stdout.write("Next: spaceapp doctor && spaceapp up && spaceapp open\n");
112
+ stdout.write(
113
+ `Next: ${UNIVERSAL_COMMAND} doctor && ${UNIVERSAL_COMMAND} up && ${UNIVERSAL_COMMAND} open\n`
114
+ );
108
115
  return 0;
109
116
  }
110
117
 
@@ -243,10 +250,10 @@ export async function run(argv, {
243
250
  }
244
251
  return code;
245
252
  }
246
- throw new Error("Usage: spaceapp uninstall [--purge-data]");
253
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} uninstall [--purge-data]`);
247
254
  }
248
255
 
249
- throw new Error(`Unknown command "${command}". Run "spaceapp help".`);
256
+ throw new Error(`Unknown command "${command}". Run "${UNIVERSAL_COMMAND} help".`);
250
257
  }
251
258
 
252
259
  async function installCommand(args, {
@@ -268,8 +275,19 @@ async function installCommand(args, {
268
275
  const { requestedProfile, noOpen } = parseInstallArgs(args);
269
276
  const resources = await inspectResources(root);
270
277
  const profile = resolveInstallProfile(requestedProfile, resources.totalMemoryBytes);
271
- const result = await initializeInstallation(root, { version, profile });
278
+ const existingConfig = await loadExistingInstallation(root);
279
+ const result = await prepareInstallation(root, { version, profile });
272
280
 
281
+ stdout.write(`Launcher version: ${version}\n`);
282
+ stdout.write(
283
+ `SpaceApp version: ${existingConfig?.version ?? "not installed"} -> ${result.config.version}\n`
284
+ );
285
+ stdout.write(
286
+ `Profile: ${existingConfig?.profile ?? "not configured"} -> ${result.config.profile}\n`
287
+ );
288
+ stdout.write(existingConfig
289
+ ? "Preserved existing data, workspaces, credentials, secrets, and persistent Docker volumes.\n"
290
+ : "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes.\n");
273
291
  stdout.write(
274
292
  `Selected profile: ${profile} (${formatGigabytes(resources.totalMemoryBytes)} GB system memory detected).\n`
275
293
  );
@@ -285,7 +303,9 @@ async function installCommand(args, {
285
303
  inspectResources,
286
304
  resources
287
305
  });
288
- stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
306
+ stderr.write(
307
+ `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n`
308
+ );
289
309
  return doctorCode;
290
310
  }
291
311
 
@@ -301,7 +321,9 @@ async function installCommand(args, {
301
321
  });
302
322
  if (prerequisiteResult.reexecuted || prerequisiteResult.code !== 0) {
303
323
  if (prerequisiteResult.code !== 0) {
304
- stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
324
+ stderr.write(
325
+ `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n`
326
+ );
305
327
  }
306
328
  return prerequisiteResult.code;
307
329
  }
@@ -318,95 +340,108 @@ async function installCommand(args, {
318
340
  dockerReady: true
319
341
  });
320
342
  if (doctorCode !== 0) {
321
- stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
322
- return doctorCode;
323
- }
324
- const pullCode = await executeWithDockerDiagnostics(
325
- execute,
326
- composeCommand("pull", root, { profile }),
327
- { stdin, stdout, stderr },
328
- { platform, stderr }
329
- );
330
- if (pullCode !== 0) return pullCode;
331
- const upCode = await executeWithDockerDiagnostics(
332
- execute,
333
- composeCommand("up", root, { profile }),
334
- { stdin, stdout, stderr },
335
- { platform, stderr }
336
- );
337
- if (upCode !== 0) return upCode;
338
-
339
- const url = `http://${result.config.bindHost}:${result.config.port}`;
340
- stdout.write("Waiting for SpaceApp services to become ready...\n");
341
- const ready = await waitForApplicationReady({
342
- url,
343
- request,
344
- sleep
345
- });
346
- if (!ready) {
347
343
  stderr.write(
348
- "SpaceApp containers started, but the application did not become ready within 3 minutes.\n" +
349
- 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
344
+ `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n`
350
345
  );
351
- return 1;
346
+ return doctorCode;
352
347
  }
353
-
354
- let setupStatus;
348
+ const stagedStateRoot = await mkdtemp(join(tmpdir(), "run-spaceapp-install-"));
355
349
  try {
356
- setupStatus = await requestSetupStatus({ url, request });
357
- } catch (error) {
358
- stderr.write(
359
- `SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
360
- 'Run "spaceapp status" and "spaceapp logs", then run "spaceapp install" again.\n'
350
+ await commitInstallation(stagedStateRoot, result.config);
351
+ const stagedComposeCommand = (action) =>
352
+ composeCommand(action, root, { profile, stateRoot: stagedStateRoot });
353
+ const pullCode = await executeWithDockerDiagnostics(
354
+ execute,
355
+ stagedComposeCommand("pull"),
356
+ { stdin, stdout, stderr },
357
+ { platform, stderr }
361
358
  );
362
- return 1;
363
- }
364
-
365
- let setupToken = null;
366
- if (setupStatus.setupRequired) {
367
- setupToken = randomBytes(32).toString("base64url");
368
- const rotateCode = await executeWithDockerDiagnostics(
359
+ if (pullCode !== 0) return pullCode;
360
+ const upCode = await executeWithDockerDiagnostics(
369
361
  execute,
370
- composeCommand("rotateOwnerSetupToken", root, { profile }),
371
- {
372
- stdin,
373
- stdout,
374
- stderr,
375
- input: `${setupToken}\n`
376
- },
362
+ stagedComposeCommand("up"),
363
+ { stdin, stdout, stderr },
377
364
  { platform, stderr }
378
365
  );
379
- if (rotateCode !== 0) {
366
+ if (upCode !== 0) return upCode;
367
+
368
+ const url = `http://${result.config.bindHost}:${result.config.port}`;
369
+ stdout.write("Waiting for SpaceApp services to become ready...\n");
370
+ const ready = await waitForApplicationReady({
371
+ url,
372
+ request,
373
+ sleep
374
+ });
375
+ if (!ready) {
380
376
  stderr.write(
381
- 'SpaceApp is ready, but a fresh owner setup token could not be created. Run "spaceapp owner rotate-setup-token".\n'
377
+ "SpaceApp containers started, but the application did not become ready within 3 minutes.\n" +
378
+ `Run "${UNIVERSAL_COMMAND} status" and "${UNIVERSAL_COMMAND} logs", then run "${UNIVERSAL_COMMAND} install" again.\n`
382
379
  );
383
- return rotateCode;
380
+ return 1;
384
381
  }
382
+
383
+ let setupStatus;
385
384
  try {
386
- await persistSetupToken(root, setupToken);
387
- } catch {
385
+ setupStatus = await requestSetupStatus({ url, request });
386
+ } catch (error) {
388
387
  stderr.write(
389
- "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
390
- 'Run "spaceapp owner rotate-setup-token" to obtain a usable token.\n'
388
+ `SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
389
+ `Run "${UNIVERSAL_COMMAND} status" and "${UNIVERSAL_COMMAND} logs", then run "${UNIVERSAL_COMMAND} install" again.\n`
391
390
  );
392
391
  return 1;
393
392
  }
394
- }
395
393
 
396
- stdout.write(`SpaceApp is ready at ${url}\n`);
397
- if (setupToken) {
398
- stdout.write(`One-time setup token: ${setupToken}\n`);
399
- stdout.write('Paste it into the "One-time setup token" field in the page that opens.\n');
400
- stdout.write("It expires in 15 minutes and stops working after the first owner is created.\n");
401
- stdout.write("If it expires, run: spaceapp owner rotate-setup-token\n");
402
- }
403
- stdout.write('Next: add CLI credentials with "spaceapp credentials set <provider>".\n');
404
- if (noOpen) return 0;
405
- const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
406
- if (openCode !== 0) {
407
- stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
394
+ let setupToken = null;
395
+ if (setupStatus.setupRequired) {
396
+ setupToken = randomBytes(32).toString("base64url");
397
+ const rotateCode = await executeWithDockerDiagnostics(
398
+ execute,
399
+ stagedComposeCommand("rotateOwnerSetupToken"),
400
+ {
401
+ stdin,
402
+ stdout,
403
+ stderr,
404
+ input: `${setupToken}\n`
405
+ },
406
+ { platform, stderr }
407
+ );
408
+ if (rotateCode !== 0) {
409
+ stderr.write(
410
+ `SpaceApp is ready, but a fresh owner setup token could not be created. Run "${UNIVERSAL_COMMAND} owner rotate-setup-token".\n`
411
+ );
412
+ return rotateCode;
413
+ }
414
+ try {
415
+ await persistSetupToken(root, setupToken);
416
+ } catch {
417
+ stderr.write(
418
+ "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
419
+ `Run "${UNIVERSAL_COMMAND} owner rotate-setup-token" to obtain a usable token.\n`
420
+ );
421
+ return 1;
422
+ }
423
+ }
424
+
425
+ await commitInstallation(root, result.config);
426
+ stdout.write(`SpaceApp is ready at ${url}\n`);
427
+ if (setupToken) {
428
+ stdout.write(`One-time setup token: ${setupToken}\n`);
429
+ stdout.write('Paste it into the "One-time setup token" field in the page that opens.\n');
430
+ stdout.write("It expires in 15 minutes and stops working after the first owner is created.\n");
431
+ stdout.write(`If it expires, run: ${UNIVERSAL_COMMAND} owner rotate-setup-token\n`);
432
+ }
433
+ stdout.write(
434
+ `Next: add CLI credentials with "${UNIVERSAL_COMMAND} credentials set <provider>".\n`
435
+ );
436
+ if (noOpen) return 0;
437
+ const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
438
+ if (openCode !== 0) {
439
+ stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
440
+ }
441
+ return 0;
442
+ } finally {
443
+ await rm(stagedStateRoot, { recursive: true, force: true });
408
444
  }
409
- return 0;
410
445
  }
411
446
 
412
447
  function parseInstallArgs(args) {
@@ -427,19 +462,36 @@ function parseInstallArgs(args) {
427
462
  requestedProfile = argument.slice("--profile=".length);
428
463
  continue;
429
464
  }
430
- throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
465
+ throw new Error(
466
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
467
+ );
431
468
  }
432
469
  if (!["auto", "light", "standard"].includes(requestedProfile)) {
433
- throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
470
+ throw new Error(
471
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
472
+ );
434
473
  }
435
474
  return { requestedProfile, noOpen };
436
475
  }
437
476
 
477
+ async function loadExistingInstallation(root) {
478
+ try {
479
+ return await loadConfig(root);
480
+ } catch (error) {
481
+ if (error?.code === "ENOENT") {
482
+ return null;
483
+ }
484
+ throw error;
485
+ }
486
+ }
487
+
438
488
  async function workspaceCommand(args, { root, config, stdout }) {
439
489
  const [action, identity, ...rest] = args;
440
490
  if (action === "add") {
441
491
  if (!identity || rest.some((arg) => arg !== "--read-only")) {
442
- throw new Error("Usage: spaceapp workspace add <absolute-path> [--read-only]");
492
+ throw new Error(
493
+ `Usage: ${UNIVERSAL_COMMAND} workspace add <absolute-path> [--read-only]`
494
+ );
443
495
  }
444
496
  const updated = await addWorkspace(config, identity, { readOnly: rest.includes("--read-only") });
445
497
  await saveConfig(root, updated);
@@ -449,7 +501,9 @@ async function workspaceCommand(args, { root, config, stdout }) {
449
501
  }
450
502
  if (action === "remove") {
451
503
  if (!identity || rest.length > 0) {
452
- throw new Error("Usage: spaceapp workspace remove <id-or-absolute-path>");
504
+ throw new Error(
505
+ `Usage: ${UNIVERSAL_COMMAND} workspace remove <id-or-absolute-path>`
506
+ );
453
507
  }
454
508
  const updated = removeWorkspace(config, identity);
455
509
  await saveConfig(root, updated);
@@ -461,7 +515,7 @@ async function workspaceCommand(args, { root, config, stdout }) {
461
515
  stdout.write(`${JSON.stringify(config.workspaces, null, 2)}\n`);
462
516
  return 0;
463
517
  }
464
- throw new Error("Usage: spaceapp workspace <add|remove|list>");
518
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} workspace <add|remove|list>`);
465
519
  }
466
520
 
467
521
  async function credentialsCommand(args, { root, config, stdin, stdout, stderr, execute }) {
@@ -472,7 +526,9 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
472
526
  }
473
527
  if (action === "set") {
474
528
  if (!provider || rest.length > 0) {
475
- throw new Error("Usage: spaceapp credentials set <provider> (the value is read from stdin)");
529
+ throw new Error(
530
+ `Usage: ${UNIVERSAL_COMMAND} credentials set <provider> (the value is read from stdin)`
531
+ );
476
532
  }
477
533
  const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `);
478
534
  await writeCredential(root, provider, value);
@@ -489,7 +545,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
489
545
  }
490
546
  if (action === "remove") {
491
547
  if (!provider || rest.length > 0) {
492
- throw new Error("Usage: spaceapp credentials remove <provider>");
548
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} credentials remove <provider>`);
493
549
  }
494
550
  const removed = await removeCredential(root, provider);
495
551
  const syncCode = await execute(
@@ -503,12 +559,12 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
503
559
  stdout.write(removed ? `Credential removed and applied for ${provider}.\n` : `No credential stored for ${provider}.\n`);
504
560
  return syncCode;
505
561
  }
506
- throw new Error("Usage: spaceapp credentials <set|remove|list>");
562
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} credentials <set|remove|list>`);
507
563
  }
508
564
 
509
565
  async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
510
566
  if (args.length !== 2 || args[0] !== "install" || args[1] !== "claude") {
511
- throw new Error("Usage: spaceapp provider install claude");
567
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install claude`);
512
568
  }
513
569
  stdout.write("Installing Claude Code from Anthropic into this installation's private provider volume.\n");
514
570
  return execute(composeCommand("installClaude", root, { profile: config.profile }), { stdin, stdout, stderr });
@@ -530,7 +586,9 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
530
586
  return 0;
531
587
  }
532
588
  if (args.length !== 1 || args[0] !== "reset-password") {
533
- throw new Error("Usage: spaceapp owner <reset-password|rotate-setup-token>");
589
+ throw new Error(
590
+ `Usage: ${UNIVERSAL_COMMAND} owner <reset-password|rotate-setup-token>`
591
+ );
534
592
  }
535
593
  const password = await readSecret(stdin, stdout, "New owner password: ");
536
594
  if (password.length < 12) {
@@ -546,7 +604,7 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
546
604
 
547
605
  async function updateCommand(args, { root, config, version, stdin, stdout, stderr, execute }) {
548
606
  if (args.length > 1) {
549
- throw new Error("Usage: spaceapp update [version]");
607
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
550
608
  }
551
609
  const targetVersion = args[0] || version;
552
610
  const updated = {
@@ -812,7 +870,6 @@ function spawnTrustedCommand(spec, options) {
812
870
  switch (spec.command) {
813
871
  case "codesign": return spawn("codesign", args, options);
814
872
  case "docker": return spawn("docker", args, options);
815
- case "explorer.exe": return spawn("explorer.exe", args, options);
816
873
  case "hdiutil": return spawn("hdiutil", args, options);
817
874
  case "open": return spawn("open", args, options);
818
875
  case "powershell.exe":
@@ -847,14 +904,18 @@ function openBrowser(url, platform, execute, io) {
847
904
  return execute({ command: "open", args: [url] }, io);
848
905
  }
849
906
  if (platform === "win32") {
850
- return execute({ command: "explorer.exe", args: [url] }, io);
907
+ return execute({
908
+ command: "powershell.exe",
909
+ operation: "open-spaceapp-browser",
910
+ env: { SPACEAPP_OPEN_URL: url }
911
+ }, io);
851
912
  }
852
913
  return execute({ command: "xdg-open", args: [url] }, io);
853
914
  }
854
915
 
855
916
  function assertNoArgs(args, command) {
856
917
  if (args.length > 0) {
857
- throw new Error(`Usage: spaceapp ${command}`);
918
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} ${command}`);
858
919
  }
859
920
  }
860
921
 
@@ -884,19 +945,19 @@ function commandNeedsRuntimeFiles(command, args) {
884
945
 
885
946
  function dockerInstallHelp(platform) {
886
947
  if (platform === "win32") {
887
- return 'Run "spaceapp install" to install and start signed Docker Desktop with WSL2 automatically.';
948
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop with WSL2 automatically.`;
888
949
  }
889
950
  if (platform === "darwin") {
890
- return 'Run "spaceapp install" to install and start signed Docker Desktop automatically.';
951
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop automatically.`;
891
952
  }
892
- return 'Run "spaceapp install" to install and start Docker Engine and Compose automatically on supported Linux distributions.';
953
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start Docker Engine and Compose automatically on supported Linux distributions.`;
893
954
  }
894
955
 
895
956
  function dockerEngineHelp(platform) {
896
957
  if (platform === "win32" || platform === "darwin") {
897
- return 'Open Docker Desktop, complete any first-run prompt, then run "spaceapp doctor" again.';
958
+ return `Open Docker Desktop, complete any first-run prompt, then run "${UNIVERSAL_COMMAND} doctor" again.`;
898
959
  }
899
- return 'Start Docker Engine, verify the current user can access it, then run "spaceapp doctor" again.';
960
+ return `Start Docker Engine, verify the current user can access it, then run "${UNIVERSAL_COMMAND} doctor" again.`;
900
961
  }
901
962
 
902
963
  function formatGigabytes(bytes) {
@@ -915,7 +976,8 @@ async function packageVersion() {
915
976
  function helpText() {
916
977
  return `SpaceApp self-hosted launcher
917
978
 
918
- Usage: spaceapp <command>
979
+ Usage: ${UNIVERSAL_COMMAND} <command>
980
+ spaceapp <command> Optional global launcher
919
981
 
920
982
  init Create a local SpaceApp installation
921
983
  install [--profile auto|light|standard] [--no-open]
package/src/index.mjs CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import {
3
3
  chmod,
4
- copyFile,
5
4
  mkdir,
6
5
  readFile,
7
6
  readdir,
@@ -354,6 +353,8 @@ export function renderWorkspaceCompose(config) {
354
353
 
355
354
  export function composeCommand(action, root, options = {}) {
356
355
  validateHome(root);
356
+ const stateRoot = options.stateRoot ?? root;
357
+ validateHome(stateRoot);
357
358
  const profile = options.profile ?? "standard";
358
359
  if (profile !== "light" && profile !== "standard") {
359
360
  throw new Error("Compose profile must be light or standard.");
@@ -362,9 +363,9 @@ export function composeCommand(action, root, options = {}) {
362
363
  "compose",
363
364
  "--project-name", composeProjectName(root),
364
365
  "--project-directory", root,
365
- "--env-file", join(root, "runtime.env"),
366
- "-f", join(root, "compose.yml"),
367
- "-f", join(root, "compose.workspaces.yml"),
366
+ "--env-file", join(stateRoot, "runtime.env"),
367
+ "-f", join(stateRoot, "compose.yml"),
368
+ "-f", join(stateRoot, "compose.workspaces.yml"),
368
369
  ...(profile === "standard" ? ["--profile", "standard"] : [])
369
370
  ];
370
371
  const actions = {
@@ -459,7 +460,9 @@ export async function selectLatestBackupId(root) {
459
460
  .sort()
460
461
  .at(-1);
461
462
  if (!backupId) {
462
- throw new Error('No portable backup exists. Run "spaceapp backup" before restore.');
463
+ throw new Error(
464
+ 'No portable backup exists. Run "npx --yes run-spaceapp@latest backup" before restore.'
465
+ );
463
466
  }
464
467
  return backupId;
465
468
  }
@@ -472,9 +475,8 @@ export async function writeRuntimeFiles(root, config) {
472
475
  await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config));
473
476
  }
474
477
 
475
- export async function initializeInstallation(root, {
478
+ export async function prepareInstallation(root, {
476
479
  version,
477
- templateDir = defaultTemplateDir(),
478
480
  profile
479
481
  }) {
480
482
  validateHome(root);
@@ -505,10 +507,7 @@ export async function initializeInstallation(root, {
505
507
  if (resolvedProfile !== undefined) {
506
508
  config = { ...config, profile: resolvedProfile };
507
509
  }
508
- await saveConfig(root, config);
509
- await copyFile(join(templateDir, "compose.yml"), join(root, "compose.yml"));
510
- await chmod(join(root, "compose.yml"), 0o600);
511
- await writeRuntimeFiles(root, config);
510
+ validateConfig(config);
512
511
 
513
512
  const postgresPasswordPath = join(root, "secrets", "postgres-password");
514
513
  let postgresPassword;
@@ -559,6 +558,26 @@ export async function initializeInstallation(root, {
559
558
  return { config, setupToken };
560
559
  }
561
560
 
561
+ export async function commitInstallation(root, config, {
562
+ templateDir = defaultTemplateDir()
563
+ } = {}) {
564
+ validateHome(root);
565
+ validateConfig(config);
566
+ const composeTemplate = await readFile(join(templateDir, "compose.yml"), "utf8");
567
+ await mkdir(root, { recursive: true, mode: 0o700 });
568
+ await atomicWrite(join(root, "compose.yml"), composeTemplate);
569
+ await writeRuntimeFiles(root, config);
570
+ await saveConfig(root, config);
571
+ }
572
+
573
+ export async function initializeInstallation(root, options) {
574
+ const result = await prepareInstallation(root, options);
575
+ await commitInstallation(root, result.config, {
576
+ templateDir: options.templateDir
577
+ });
578
+ return result;
579
+ }
580
+
562
581
  function validateWorkspace(workspace) {
563
582
  if (!workspace || typeof workspace !== "object") {
564
583
  throw new Error("Invalid workspace entry.");
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
 
12
12
  const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/";
13
13
  const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000;
14
+ const UNIVERSAL_INSTALL_COMMAND = "npx --yes run-spaceapp@latest install";
14
15
  const WINDOWS_DOCKER_DOWNLOADS = Object.freeze({
15
16
  x64: "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe",
16
17
  arm64: "https://desktop.docker.com/win/main/arm64/Docker%20Desktop%20Installer.exe"
@@ -73,6 +74,17 @@ export function windowsPowerShellArgs(operation) {
73
74
  "$path = $env:SPACEAPP_DOCKER_DESKTOP_PATH",
74
75
  "if ([string]::IsNullOrWhiteSpace($path)) { exit 1 }",
75
76
  "Start-Process -FilePath $path"
77
+ ].join("; "),
78
+ "open-spaceapp-browser": [
79
+ "$ErrorActionPreference = 'Stop'",
80
+ "$url = $env:SPACEAPP_OPEN_URL",
81
+ "$uri = $null",
82
+ "if ([string]::IsNullOrWhiteSpace($url) -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri)) { exit 1 }",
83
+ "if ($uri.Scheme -ne 'http' -or @('127.0.0.1','0.0.0.0') -notcontains $uri.Host -or $uri.Port -lt 1024 -or $uri.Port -gt 65535) { exit 1 }",
84
+ "$roots = @([Environment]::GetEnvironmentVariable('ProgramFiles(x86)'), [Environment]::GetEnvironmentVariable('ProgramFiles'), [Environment]::GetEnvironmentVariable('LOCALAPPDATA')) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }",
85
+ "$edge = $roots | ForEach-Object { Join-Path $_ 'Microsoft\\Edge\\Application\\msedge.exe' } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1",
86
+ "if ($edge) { Start-Process -FilePath $edge -ArgumentList @('--no-first-run','--no-default-browser-check',$url); exit 0 }",
87
+ "Start-Process -FilePath 'explorer.exe' -ArgumentList @($url)"
76
88
  ].join("; ")
77
89
  };
78
90
  if (!Object.hasOwn(scripts, operation)) {
@@ -210,7 +222,7 @@ async function ensureWindowsDocker({
210
222
  );
211
223
  if (installWslCode !== 0) {
212
224
  stderr.write(
213
- "Windows could not enable WSL2 automatically. Run this command from an Administrator terminal, restart Windows if requested, and run it again.\n"
225
+ `Windows could not enable WSL2 automatically. Run "${UNIVERSAL_INSTALL_COMMAND}" from an Administrator Command Prompt, restart Windows if requested, and run it again.\n`
214
226
  );
215
227
  return { code: installWslCode, reexecuted: false };
216
228
  }
@@ -232,7 +244,7 @@ async function ensureWindowsDocker({
232
244
  if (scheduleCode !== 0) {
233
245
  stderr.write(
234
246
  "Windows must restart to finish WSL2, but SpaceApp could not schedule automatic continuation.\n" +
235
- "Restart Windows, then run the same SpaceApp command again.\n"
247
+ `Restart Windows, then run "${UNIVERSAL_INSTALL_COMMAND}" again.\n`
236
248
  );
237
249
  return { code: scheduleCode, reexecuted: false };
238
250
  }
@@ -375,7 +387,7 @@ async function ensureWindowsDocker({
375
387
  return { code: 0, reexecuted: false };
376
388
  }
377
389
  stderr.write(
378
- "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"
390
+ `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 "${UNIVERSAL_INSTALL_COMMAND}" again.\n`
379
391
  );
380
392
  return { code: 1, reexecuted: false };
381
393
  }
@@ -483,7 +495,7 @@ async function ensureMacDocker({
483
495
  return { code: 0, reexecuted: false };
484
496
  }
485
497
  stderr.write(
486
- "Docker Desktop was installed but did not become ready. Complete any Docker Desktop prompt, then run the same SpaceApp command again.\n"
498
+ `Docker Desktop was installed but did not become ready. Complete any Docker Desktop prompt, then run "${UNIVERSAL_INSTALL_COMMAND}" again.\n`
487
499
  );
488
500
  return { code: 1, reexecuted: false };
489
501
  }