run-spaceapp 0.1.12 → 0.1.14

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.12",
3
+ "version": "0.1.14",
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": "55dff6314db5ded932361275b85a5a2008920128"
49
+ "gitHead": "f6956f38649503f6a9dd5c232d3d51d7bf2fa258"
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,6 +34,7 @@ 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",
@@ -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,129 @@ 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
+ }
415
+
416
+ if (profile === "light") {
417
+ const removeBrowserCode = await executeWithDockerDiagnostics(
418
+ execute,
419
+ composeCommand("removeBrowser", root, {
420
+ profile: "standard",
421
+ stateRoot: stagedStateRoot
422
+ }),
423
+ { stdin, stdout, stderr },
424
+ { platform, stderr }
425
+ );
426
+ if (removeBrowserCode !== 0) {
427
+ stderr.write(
428
+ "SpaceApp is ready, but the managed browser container could not be removed for the light profile.\n"
429
+ );
430
+ return removeBrowserCode;
431
+ }
432
+ }
433
+
434
+ if (setupToken) {
435
+ try {
436
+ await persistSetupToken(root, setupToken);
437
+ } catch {
438
+ stderr.write(
439
+ "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
440
+ `Run "${UNIVERSAL_COMMAND} owner rotate-setup-token" to obtain a usable token.\n`
441
+ );
442
+ return 1;
443
+ }
444
+ }
445
+
446
+ await commitInstallation(root, result.config);
447
+ stdout.write(`SpaceApp is ready at ${url}\n`);
448
+ if (setupToken) {
449
+ stdout.write(`One-time setup token: ${setupToken}\n`);
450
+ stdout.write('Paste it into the "One-time setup token" field in the page that opens.\n');
451
+ stdout.write("It expires in 15 minutes and stops working after the first owner is created.\n");
452
+ stdout.write(`If it expires, run: ${UNIVERSAL_COMMAND} owner rotate-setup-token\n`);
453
+ }
454
+ stdout.write(
455
+ `Next: add CLI credentials with "${UNIVERSAL_COMMAND} credentials set <provider>".\n`
456
+ );
457
+ if (noOpen) return 0;
458
+ const openCode = await openBrowser(url, platform, execute, { stdin, stdout, stderr });
459
+ if (openCode !== 0) {
460
+ stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
461
+ }
462
+ return 0;
463
+ } finally {
464
+ await rm(stagedStateRoot, { recursive: true, force: true });
408
465
  }
409
- return 0;
410
466
  }
411
467
 
412
468
  function parseInstallArgs(args) {
@@ -427,19 +483,36 @@ function parseInstallArgs(args) {
427
483
  requestedProfile = argument.slice("--profile=".length);
428
484
  continue;
429
485
  }
430
- throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
486
+ throw new Error(
487
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
488
+ );
431
489
  }
432
490
  if (!["auto", "light", "standard"].includes(requestedProfile)) {
433
- throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
491
+ throw new Error(
492
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
493
+ );
434
494
  }
435
495
  return { requestedProfile, noOpen };
436
496
  }
437
497
 
498
+ async function loadExistingInstallation(root) {
499
+ try {
500
+ return await loadConfig(root);
501
+ } catch (error) {
502
+ if (error?.code === "ENOENT") {
503
+ return null;
504
+ }
505
+ throw error;
506
+ }
507
+ }
508
+
438
509
  async function workspaceCommand(args, { root, config, stdout }) {
439
510
  const [action, identity, ...rest] = args;
440
511
  if (action === "add") {
441
512
  if (!identity || rest.some((arg) => arg !== "--read-only")) {
442
- throw new Error("Usage: spaceapp workspace add <absolute-path> [--read-only]");
513
+ throw new Error(
514
+ `Usage: ${UNIVERSAL_COMMAND} workspace add <absolute-path> [--read-only]`
515
+ );
443
516
  }
444
517
  const updated = await addWorkspace(config, identity, { readOnly: rest.includes("--read-only") });
445
518
  await saveConfig(root, updated);
@@ -449,7 +522,9 @@ async function workspaceCommand(args, { root, config, stdout }) {
449
522
  }
450
523
  if (action === "remove") {
451
524
  if (!identity || rest.length > 0) {
452
- throw new Error("Usage: spaceapp workspace remove <id-or-absolute-path>");
525
+ throw new Error(
526
+ `Usage: ${UNIVERSAL_COMMAND} workspace remove <id-or-absolute-path>`
527
+ );
453
528
  }
454
529
  const updated = removeWorkspace(config, identity);
455
530
  await saveConfig(root, updated);
@@ -461,7 +536,7 @@ async function workspaceCommand(args, { root, config, stdout }) {
461
536
  stdout.write(`${JSON.stringify(config.workspaces, null, 2)}\n`);
462
537
  return 0;
463
538
  }
464
- throw new Error("Usage: spaceapp workspace <add|remove|list>");
539
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} workspace <add|remove|list>`);
465
540
  }
466
541
 
467
542
  async function credentialsCommand(args, { root, config, stdin, stdout, stderr, execute }) {
@@ -472,7 +547,9 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
472
547
  }
473
548
  if (action === "set") {
474
549
  if (!provider || rest.length > 0) {
475
- throw new Error("Usage: spaceapp credentials set <provider> (the value is read from stdin)");
550
+ throw new Error(
551
+ `Usage: ${UNIVERSAL_COMMAND} credentials set <provider> (the value is read from stdin)`
552
+ );
476
553
  }
477
554
  const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `);
478
555
  await writeCredential(root, provider, value);
@@ -489,7 +566,7 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
489
566
  }
490
567
  if (action === "remove") {
491
568
  if (!provider || rest.length > 0) {
492
- throw new Error("Usage: spaceapp credentials remove <provider>");
569
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} credentials remove <provider>`);
493
570
  }
494
571
  const removed = await removeCredential(root, provider);
495
572
  const syncCode = await execute(
@@ -503,12 +580,12 @@ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, e
503
580
  stdout.write(removed ? `Credential removed and applied for ${provider}.\n` : `No credential stored for ${provider}.\n`);
504
581
  return syncCode;
505
582
  }
506
- throw new Error("Usage: spaceapp credentials <set|remove|list>");
583
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} credentials <set|remove|list>`);
507
584
  }
508
585
 
509
586
  async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
510
587
  if (args.length !== 2 || args[0] !== "install" || args[1] !== "claude") {
511
- throw new Error("Usage: spaceapp provider install claude");
588
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} provider install claude`);
512
589
  }
513
590
  stdout.write("Installing Claude Code from Anthropic into this installation's private provider volume.\n");
514
591
  return execute(composeCommand("installClaude", root, { profile: config.profile }), { stdin, stdout, stderr });
@@ -530,7 +607,9 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
530
607
  return 0;
531
608
  }
532
609
  if (args.length !== 1 || args[0] !== "reset-password") {
533
- throw new Error("Usage: spaceapp owner <reset-password|rotate-setup-token>");
610
+ throw new Error(
611
+ `Usage: ${UNIVERSAL_COMMAND} owner <reset-password|rotate-setup-token>`
612
+ );
534
613
  }
535
614
  const password = await readSecret(stdin, stdout, "New owner password: ");
536
615
  if (password.length < 12) {
@@ -546,7 +625,7 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
546
625
 
547
626
  async function updateCommand(args, { root, config, version, stdin, stdout, stderr, execute }) {
548
627
  if (args.length > 1) {
549
- throw new Error("Usage: spaceapp update [version]");
628
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
550
629
  }
551
630
  const targetVersion = args[0] || version;
552
631
  const updated = {
@@ -857,7 +936,7 @@ function openBrowser(url, platform, execute, io) {
857
936
 
858
937
  function assertNoArgs(args, command) {
859
938
  if (args.length > 0) {
860
- throw new Error(`Usage: spaceapp ${command}`);
939
+ throw new Error(`Usage: ${UNIVERSAL_COMMAND} ${command}`);
861
940
  }
862
941
  }
863
942
 
@@ -887,19 +966,19 @@ function commandNeedsRuntimeFiles(command, args) {
887
966
 
888
967
  function dockerInstallHelp(platform) {
889
968
  if (platform === "win32") {
890
- return 'Run "spaceapp install" to install and start signed Docker Desktop with WSL2 automatically.';
969
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop with WSL2 automatically.`;
891
970
  }
892
971
  if (platform === "darwin") {
893
- return 'Run "spaceapp install" to install and start signed Docker Desktop automatically.';
972
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop automatically.`;
894
973
  }
895
- return 'Run "spaceapp install" to install and start Docker Engine and Compose automatically on supported Linux distributions.';
974
+ return `Run "${UNIVERSAL_COMMAND} install" to install and start Docker Engine and Compose automatically on supported Linux distributions.`;
896
975
  }
897
976
 
898
977
  function dockerEngineHelp(platform) {
899
978
  if (platform === "win32" || platform === "darwin") {
900
- return 'Open Docker Desktop, complete any first-run prompt, then run "spaceapp doctor" again.';
979
+ return `Open Docker Desktop, complete any first-run prompt, then run "${UNIVERSAL_COMMAND} doctor" again.`;
901
980
  }
902
- return 'Start Docker Engine, verify the current user can access it, then run "spaceapp doctor" again.';
981
+ return `Start Docker Engine, verify the current user can access it, then run "${UNIVERSAL_COMMAND} doctor" again.`;
903
982
  }
904
983
 
905
984
  function formatGigabytes(bytes) {
@@ -918,7 +997,8 @@ async function packageVersion() {
918
997
  function helpText() {
919
998
  return `SpaceApp self-hosted launcher
920
999
 
921
- Usage: spaceapp <command>
1000
+ Usage: ${UNIVERSAL_COMMAND} <command>
1001
+ spaceapp <command> Optional global launcher
922
1002
 
923
1003
  init Create a local SpaceApp installation
924
1004
  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 = {
@@ -412,6 +413,7 @@ export function composeCommand(action, root, options = {}) {
412
413
  "scripts/rotate-owner-setup-token.mjs",
413
414
  "--stdin"
414
415
  ],
416
+ removeBrowser: ["rm", "--stop", "--force", "spaceapp-browser"],
415
417
  purge: ["down", "--volumes", "--remove-orphans"]
416
418
  };
417
419
  let selected = actions[action];
@@ -459,7 +461,9 @@ export async function selectLatestBackupId(root) {
459
461
  .sort()
460
462
  .at(-1);
461
463
  if (!backupId) {
462
- throw new Error('No portable backup exists. Run "spaceapp backup" before restore.');
464
+ throw new Error(
465
+ 'No portable backup exists. Run "npx --yes run-spaceapp@latest backup" before restore.'
466
+ );
463
467
  }
464
468
  return backupId;
465
469
  }
@@ -472,9 +476,8 @@ export async function writeRuntimeFiles(root, config) {
472
476
  await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config));
473
477
  }
474
478
 
475
- export async function initializeInstallation(root, {
479
+ export async function prepareInstallation(root, {
476
480
  version,
477
- templateDir = defaultTemplateDir(),
478
481
  profile
479
482
  }) {
480
483
  validateHome(root);
@@ -505,10 +508,7 @@ export async function initializeInstallation(root, {
505
508
  if (resolvedProfile !== undefined) {
506
509
  config = { ...config, profile: resolvedProfile };
507
510
  }
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);
511
+ validateConfig(config);
512
512
 
513
513
  const postgresPasswordPath = join(root, "secrets", "postgres-password");
514
514
  let postgresPassword;
@@ -559,6 +559,26 @@ export async function initializeInstallation(root, {
559
559
  return { config, setupToken };
560
560
  }
561
561
 
562
+ export async function commitInstallation(root, config, {
563
+ templateDir = defaultTemplateDir()
564
+ } = {}) {
565
+ validateHome(root);
566
+ validateConfig(config);
567
+ const composeTemplate = await readFile(join(templateDir, "compose.yml"), "utf8");
568
+ await mkdir(root, { recursive: true, mode: 0o700 });
569
+ await atomicWrite(join(root, "compose.yml"), composeTemplate);
570
+ await writeRuntimeFiles(root, config);
571
+ await saveConfig(root, config);
572
+ }
573
+
574
+ export async function initializeInstallation(root, options) {
575
+ const result = await prepareInstallation(root, options);
576
+ await commitInstallation(root, result.config, {
577
+ templateDir: options.templateDir
578
+ });
579
+ return result;
580
+ }
581
+
562
582
  function validateWorkspace(workspace) {
563
583
  if (!workspace || typeof workspace !== "object") {
564
584
  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"
@@ -221,7 +222,7 @@ async function ensureWindowsDocker({
221
222
  );
222
223
  if (installWslCode !== 0) {
223
224
  stderr.write(
224
- "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`
225
226
  );
226
227
  return { code: installWslCode, reexecuted: false };
227
228
  }
@@ -243,7 +244,7 @@ async function ensureWindowsDocker({
243
244
  if (scheduleCode !== 0) {
244
245
  stderr.write(
245
246
  "Windows must restart to finish WSL2, but SpaceApp could not schedule automatic continuation.\n" +
246
- "Restart Windows, then run the same SpaceApp command again.\n"
247
+ `Restart Windows, then run "${UNIVERSAL_INSTALL_COMMAND}" again.\n`
247
248
  );
248
249
  return { code: scheduleCode, reexecuted: false };
249
250
  }
@@ -386,7 +387,7 @@ async function ensureWindowsDocker({
386
387
  return { code: 0, reexecuted: false };
387
388
  }
388
389
  stderr.write(
389
- "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`
390
391
  );
391
392
  return { code: 1, reexecuted: false };
392
393
  }
@@ -494,7 +495,7 @@ async function ensureMacDocker({
494
495
  return { code: 0, reexecuted: false };
495
496
  }
496
497
  stderr.write(
497
- "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`
498
499
  );
499
500
  return { code: 1, reexecuted: false };
500
501
  }