run-spaceapp 0.1.13 → 0.1.15-hostroot.0

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
@@ -19,8 +19,9 @@ The launcher requires Node.js 20.11 or newer, 4 CPUs, 8 GB RAM, and 15 GiB free
19
19
  disk. Keep `@latest` in the command so npm resolves the current release instead
20
20
  of matching an existing local launcher. If Docker is missing, the universal
21
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
22
+ Windows, macOS, Ubuntu, Debian, Fedora, RHEL, and CentOS, or with native
23
+ `pacman` packages on Arch-family Linux including CachyOS. It starts Docker and
24
+ waits for Engine and Compose readiness before pulling images. Windows prefers
24
25
  Windows Package Manager's hash-pinned Docker Desktop
25
26
  manifest and retains a signed direct-download fallback. Docker Desktop license
26
27
  acceptance and Linux `docker` group membership require confirmation inside the
@@ -50,6 +51,27 @@ current user's platform config directory:
50
51
 
51
52
  Set `SPACEAPP_HOME` to an absolute path to use a dedicated installation root.
52
53
 
54
+ Linux installations are isolated by default. A trusted single owner can opt
55
+ into the personal host-root prerelease with:
56
+
57
+ ```bash
58
+ npx --yes run-spaceapp@personal install --access host-root
59
+ ```
60
+
61
+ This mounts the Linux `/` at `/host` read/write in the CLI service and runs CLI
62
+ sessions as container root. The core service receives `/host` read-only. It can
63
+ expose every host credential or make the operating system unbootable. It does
64
+ not mount the Docker socket or enable a privileged container. Profiles control
65
+ resources and managed Chromium, not access. Return to isolation without
66
+ deleting persistent data with:
67
+
68
+ ```bash
69
+ npx --yes run-spaceapp@personal install --access isolated
70
+ ```
71
+
72
+ Use the same `npx --yes run-spaceapp@personal` prefix for follow-up commands
73
+ while this prerelease is installed.
74
+
53
75
  Provider API keys are read from masked standard input and written to
54
76
  restrictive files outside Git. OAuth and device-code credentials remain in
55
77
  isolated Docker provider state. Neither credential source is included in
@@ -59,10 +81,10 @@ Claude Code is not redistributed in SpaceApp images. Install the reviewed
59
81
  package as an explicit owner action with:
60
82
 
61
83
  ```bash
62
- npx --yes run-spaceapp@latest provider install claude
84
+ npx --yes run-spaceapp@personal provider install claude
63
85
  ```
64
86
 
65
- Run `npx --yes run-spaceapp@latest help` for the complete command list. Full
87
+ Run `npx --yes run-spaceapp@personal help` for the complete command list. Full
66
88
  documentation:
67
89
 
68
90
  - [Getting started](https://github.com/oll4com/spaceapp/blob/main/docs/getting-started.md)
@@ -75,16 +97,16 @@ After the stack passes readiness checks, the install command prints the fresh
75
97
  it expires before the owner is created, run:
76
98
 
77
99
  ```bash
78
- npx --yes run-spaceapp@latest owner rotate-setup-token
100
+ npx --yes run-spaceapp@personal owner rotate-setup-token
79
101
  ```
80
102
 
81
103
  Common follow-up commands use the same reliable launcher prefix:
82
104
 
83
105
  ```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
106
+ npx --yes run-spaceapp@personal doctor
107
+ npx --yes run-spaceapp@personal status
108
+ npx --yes run-spaceapp@personal credentials list
109
+ npx --yes run-spaceapp@personal uninstall
88
110
  ```
89
111
 
90
112
  Uninstall retains data by default and prints the separate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run-spaceapp",
3
- "version": "0.1.13",
3
+ "version": "0.1.15-hostroot.0",
4
4
  "description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node --test tests/*.test.mjs",
33
- "check": "node --check bin/spaceapp.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/prerequisites.mjs",
33
+ "check": "node --check bin/spaceapp.mjs && node --check src/package-info.mjs && node --check src/index.mjs && node --check src/cli.mjs && node --check src/prerequisites.mjs",
34
34
  "pack:dry-run": "npm pack --dry-run"
35
35
  },
36
36
  "repository": {
@@ -46,5 +46,5 @@
46
46
  "access": "public",
47
47
  "provenance": true
48
48
  },
49
- "gitHead": "14a0f1117141c24c3436768c23e819f482eaed70"
49
+ "gitHead": "5a8c1b92709bbd9f88b5ed869adb7ddfaea9092f"
50
50
  }
package/src/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  removeCredential,
17
17
  removeWorkspace,
18
18
  prepareInstallation,
19
+ resolveInstallAccessMode,
19
20
  resolveInstallProfile,
20
21
  resolveSpaceAppHome,
21
22
  saveConfig,
@@ -29,12 +30,15 @@ import {
29
30
  prepareDockerCliPath,
30
31
  windowsPowerShellArgs
31
32
  } from "./prerequisites.mjs";
33
+ import {
34
+ PACKAGE_VERSION,
35
+ UNIVERSAL_COMMAND
36
+ } from "./package-info.mjs";
32
37
 
33
38
  const APPLICATION_READY_WAIT_MS = 3 * 60 * 1_000;
34
39
  const APPLICATION_READY_POLL_MS = 2_000;
35
40
  const APPLICATION_READY_MAX_ATTEMPTS = APPLICATION_READY_WAIT_MS / APPLICATION_READY_POLL_MS;
36
41
  const SETUP_STATUS_TIMEOUT_MS = 10_000;
37
- const UNIVERSAL_COMMAND = "npx --yes run-spaceapp@latest";
38
42
  const trustedCommands = new Set([
39
43
  "codesign",
40
44
  "docker",
@@ -70,11 +74,13 @@ export async function run(argv, {
70
74
  sleep = wait,
71
75
  persistSetupToken = writeSetupToken
72
76
  } = {}) {
73
- await prepareDockerPath({ platform, env });
74
77
  const [command = "help", ...args] = argv;
75
78
  const root = resolveSpaceAppHome({ env, platform });
76
- const version = await packageVersion();
79
+ const version = PACKAGE_VERSION;
77
80
 
81
+ if (command !== "install") {
82
+ await prepareDockerPath({ platform, env });
83
+ }
78
84
  if (command === "help" || command === "--help" || command === "-h") {
79
85
  stdout.write(helpText());
80
86
  return 0;
@@ -96,6 +102,7 @@ export async function run(argv, {
96
102
  execute,
97
103
  inspectResources,
98
104
  ensureDocker,
105
+ prepareDockerPath,
99
106
  request,
100
107
  sleep,
101
108
  persistSetupToken
@@ -268,15 +275,29 @@ async function installCommand(args, {
268
275
  execute,
269
276
  inspectResources,
270
277
  ensureDocker,
278
+ prepareDockerPath,
271
279
  request,
272
280
  sleep,
273
281
  persistSetupToken
274
282
  }) {
275
- const { requestedProfile, noOpen } = parseInstallArgs(args);
283
+ const { requestedProfile, requestedAccessMode, noOpen } = parseInstallArgs(args);
284
+ const existingConfig = await loadExistingInstallation(root);
285
+ const accessMode = resolveInstallAccessMode(
286
+ requestedAccessMode,
287
+ existingConfig?.accessMode ?? "isolated"
288
+ );
289
+ if (accessMode === "host-root" && platform !== "linux") {
290
+ throw new Error("Host-root access is supported only on Linux.");
291
+ }
292
+ await prepareDockerPath({ platform, env });
276
293
  const resources = await inspectResources(root);
277
294
  const profile = resolveInstallProfile(requestedProfile, resources.totalMemoryBytes);
278
- const existingConfig = await loadExistingInstallation(root);
279
- const result = await prepareInstallation(root, { version, profile });
295
+ if (accessMode === "host-root") {
296
+ stderr.write(
297
+ "WARNING: host-root access lets SpaceApp CLI sessions read and modify the entire Linux host through /host, including credentials and system files.\n"
298
+ );
299
+ }
300
+ const result = await prepareInstallation(root, { version, profile, accessMode });
280
301
 
281
302
  stdout.write(`Launcher version: ${version}\n`);
282
303
  stdout.write(
@@ -285,6 +306,9 @@ async function installCommand(args, {
285
306
  stdout.write(
286
307
  `Profile: ${existingConfig?.profile ?? "not configured"} -> ${result.config.profile}\n`
287
308
  );
309
+ stdout.write(
310
+ `Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\n`
311
+ );
288
312
  stdout.write(existingConfig
289
313
  ? "Preserved existing data, workspaces, credentials, secrets, and persistent Docker volumes.\n"
290
314
  : "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes.\n");
@@ -317,7 +341,7 @@ async function installCommand(args, {
317
341
  stdout,
318
342
  stderr,
319
343
  execute,
320
- installArgs: { root, requestedProfile, noOpen }
344
+ installArgs: { root, requestedProfile, requestedAccessMode, noOpen }
321
345
  });
322
346
  if (prerequisiteResult.reexecuted || prerequisiteResult.code !== 0) {
323
347
  if (prerequisiteResult.code !== 0) {
@@ -346,6 +370,24 @@ async function installCommand(args, {
346
370
  return doctorCode;
347
371
  }
348
372
  const stagedStateRoot = await mkdtemp(join(tmpdir(), "run-spaceapp-install-"));
373
+ let runtimeMutationAttempted = false;
374
+ const failAfterRuntimeMutation = async (code) => {
375
+ if (runtimeMutationAttempted) {
376
+ await restoreRuntimeAfterFailedInstall({
377
+ root,
378
+ stagedStateRoot,
379
+ existingConfig,
380
+ attemptedProfile: profile,
381
+ platform,
382
+ stdin,
383
+ stdout,
384
+ stderr,
385
+ execute
386
+ });
387
+ runtimeMutationAttempted = false;
388
+ }
389
+ return code;
390
+ };
349
391
  try {
350
392
  await commitInstallation(stagedStateRoot, result.config);
351
393
  const stagedComposeCommand = (action) =>
@@ -357,13 +399,14 @@ async function installCommand(args, {
357
399
  { platform, stderr }
358
400
  );
359
401
  if (pullCode !== 0) return pullCode;
402
+ runtimeMutationAttempted = true;
360
403
  const upCode = await executeWithDockerDiagnostics(
361
404
  execute,
362
405
  stagedComposeCommand("up"),
363
406
  { stdin, stdout, stderr },
364
407
  { platform, stderr }
365
408
  );
366
- if (upCode !== 0) return upCode;
409
+ if (upCode !== 0) return await failAfterRuntimeMutation(upCode);
367
410
 
368
411
  const url = `http://${result.config.bindHost}:${result.config.port}`;
369
412
  stdout.write("Waiting for SpaceApp services to become ready...\n");
@@ -377,7 +420,7 @@ async function installCommand(args, {
377
420
  "SpaceApp containers started, but the application did not become ready within 3 minutes.\n" +
378
421
  `Run "${UNIVERSAL_COMMAND} status" and "${UNIVERSAL_COMMAND} logs", then run "${UNIVERSAL_COMMAND} install" again.\n`
379
422
  );
380
- return 1;
423
+ return await failAfterRuntimeMutation(1);
381
424
  }
382
425
 
383
426
  let setupStatus;
@@ -388,7 +431,7 @@ async function installCommand(args, {
388
431
  `SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
389
432
  `Run "${UNIVERSAL_COMMAND} status" and "${UNIVERSAL_COMMAND} logs", then run "${UNIVERSAL_COMMAND} install" again.\n`
390
433
  );
391
- return 1;
434
+ return await failAfterRuntimeMutation(1);
392
435
  }
393
436
 
394
437
  let setupToken = null;
@@ -409,8 +452,29 @@ async function installCommand(args, {
409
452
  stderr.write(
410
453
  `SpaceApp is ready, but a fresh owner setup token could not be created. Run "${UNIVERSAL_COMMAND} owner rotate-setup-token".\n`
411
454
  );
412
- return rotateCode;
455
+ return await failAfterRuntimeMutation(rotateCode);
456
+ }
457
+ }
458
+
459
+ if (profile === "light") {
460
+ const removeBrowserCode = await executeWithDockerDiagnostics(
461
+ execute,
462
+ composeCommand("removeBrowser", root, {
463
+ profile: "standard",
464
+ stateRoot: stagedStateRoot
465
+ }),
466
+ { stdin, stdout, stderr },
467
+ { platform, stderr }
468
+ );
469
+ if (removeBrowserCode !== 0) {
470
+ stderr.write(
471
+ "SpaceApp is ready, but the managed browser container could not be removed for the light profile.\n"
472
+ );
473
+ return await failAfterRuntimeMutation(removeBrowserCode);
413
474
  }
475
+ }
476
+
477
+ if (setupToken) {
414
478
  try {
415
479
  await persistSetupToken(root, setupToken);
416
480
  } catch {
@@ -418,11 +482,12 @@ async function installCommand(args, {
418
482
  "SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
419
483
  `Run "${UNIVERSAL_COMMAND} owner rotate-setup-token" to obtain a usable token.\n`
420
484
  );
421
- return 1;
485
+ return await failAfterRuntimeMutation(1);
422
486
  }
423
487
  }
424
488
 
425
489
  await commitInstallation(root, result.config);
490
+ runtimeMutationAttempted = false;
426
491
  stdout.write(`SpaceApp is ready at ${url}\n`);
427
492
  if (setupToken) {
428
493
  stdout.write(`One-time setup token: ${setupToken}\n`);
@@ -439,13 +504,81 @@ async function installCommand(args, {
439
504
  stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
440
505
  }
441
506
  return 0;
507
+ } catch (error) {
508
+ await failAfterRuntimeMutation(1);
509
+ throw error;
442
510
  } finally {
443
511
  await rm(stagedStateRoot, { recursive: true, force: true });
444
512
  }
445
513
  }
446
514
 
515
+ async function restoreRuntimeAfterFailedInstall({
516
+ root,
517
+ stagedStateRoot,
518
+ existingConfig,
519
+ attemptedProfile,
520
+ platform,
521
+ stdin,
522
+ stdout,
523
+ stderr,
524
+ execute
525
+ }) {
526
+ const runtimeExecute = (spec) => executeWithDockerDiagnostics(
527
+ execute,
528
+ spec,
529
+ { stdin, stdout, stderr },
530
+ { platform, stderr }
531
+ );
532
+ try {
533
+ if (existingConfig) {
534
+ await commitInstallation(root, existingConfig);
535
+ const restoreCode = await runtimeExecute(
536
+ composeCommand("up", root, {
537
+ profile: existingConfig.profile
538
+ })
539
+ );
540
+ if (restoreCode === 0) {
541
+ if (existingConfig.profile === "light") {
542
+ const cleanupCode = await runtimeExecute(
543
+ composeCommand("removeBrowser", root, {
544
+ profile: "standard"
545
+ })
546
+ );
547
+ if (cleanupCode !== 0) {
548
+ stderr.write(
549
+ "The previous SpaceApp runtime was restored, but an inactive browser container may remain.\n"
550
+ );
551
+ }
552
+ }
553
+ stderr.write("The previous SpaceApp runtime and access mode were restored.\n");
554
+ return;
555
+ }
556
+ stderr.write(
557
+ "The previous SpaceApp runtime could not be restored; stopping the partially updated runtime.\n"
558
+ );
559
+ }
560
+
561
+ const stopCode = await runtimeExecute(
562
+ composeCommand("down", root, {
563
+ profile: existingConfig?.profile ?? attemptedProfile,
564
+ stateRoot: existingConfig ? root : stagedStateRoot
565
+ })
566
+ );
567
+ if (stopCode !== 0) {
568
+ stderr.write(
569
+ "The partially updated SpaceApp runtime could not be stopped automatically. Run the install command again immediately.\n"
570
+ );
571
+ }
572
+ } catch (error) {
573
+ stderr.write(
574
+ `SpaceApp could not restore or stop the partially updated runtime: ${error?.message || String(error)}\n`
575
+ );
576
+ }
577
+ }
578
+
447
579
  function parseInstallArgs(args) {
448
580
  let requestedProfile = "auto";
581
+ let requestedAccessMode;
449
582
  let noOpen = false;
450
583
  for (let index = 0; index < args.length; index += 1) {
451
584
  const argument = args[index];
@@ -462,16 +595,31 @@ function parseInstallArgs(args) {
462
595
  requestedProfile = argument.slice("--profile=".length);
463
596
  continue;
464
597
  }
598
+ if (argument === "--access" && index + 1 < args.length) {
599
+ requestedAccessMode = args[index + 1];
600
+ index += 1;
601
+ continue;
602
+ }
603
+ if (argument.startsWith("--access=")) {
604
+ requestedAccessMode = argument.slice("--access=".length);
605
+ continue;
606
+ }
465
607
  throw new Error(
466
- `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
608
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]`
467
609
  );
468
610
  }
469
- if (!["auto", "light", "standard"].includes(requestedProfile)) {
611
+ if (
612
+ !["auto", "light", "standard"].includes(requestedProfile) ||
613
+ (
614
+ requestedAccessMode !== undefined &&
615
+ !["isolated", "host-root"].includes(requestedAccessMode)
616
+ )
617
+ ) {
470
618
  throw new Error(
471
- `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
619
+ `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]`
472
620
  );
473
621
  }
474
- return { requestedProfile, noOpen };
622
+ return { requestedProfile, requestedAccessMode, noOpen };
475
623
  }
476
624
 
477
625
  async function loadExistingInstallation(root) {
@@ -968,11 +1116,6 @@ function wait(milliseconds) {
968
1116
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
969
1117
  }
970
1118
 
971
- async function packageVersion() {
972
- const packageJson = new URL("../package.json", import.meta.url);
973
- return JSON.parse(await readFile(packageJson, "utf8")).version;
974
- }
975
-
976
1119
  function helpText() {
977
1120
  return `SpaceApp self-hosted launcher
978
1121
 
@@ -980,7 +1123,7 @@ Usage: ${UNIVERSAL_COMMAND} <command>
980
1123
  spaceapp <command> Optional global launcher
981
1124
 
982
1125
  init Create a local SpaceApp installation
983
- install [--profile auto|light|standard] [--no-open]
1126
+ install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]
984
1127
  Install prerequisites, initialize, and start
985
1128
  up | down | status | logs Manage the Docker application
986
1129
  open Open the local web application
package/src/index.mjs CHANGED
@@ -17,8 +17,9 @@ import {
17
17
  normalizeWorkspaceName,
18
18
  stripTrailingLineEndings
19
19
  } from "./string-utils.mjs";
20
+ import { UNIVERSAL_COMMAND } from "./package-info.mjs";
20
21
 
21
- const CONFIG_SCHEMA_VERSION = 2;
22
+ const CONFIG_SCHEMA_VERSION = 3;
22
23
  const MIN_INSTALL_CPU_COUNT = 4;
23
24
  const MIN_INSTALL_MEMORY_BYTES = 8_000_000_000;
24
25
  const MIN_INSTALL_MEMORY_LABEL = "8 GB";
@@ -68,6 +69,7 @@ const CONFIG_KEYS = new Set([
68
69
  "port",
69
70
  "telemetry",
70
71
  "profile",
72
+ "accessMode",
71
73
  "workspaces"
72
74
  ]);
73
75
 
@@ -101,6 +103,16 @@ export function resolveInstallProfile(requestedProfile, totalMemoryBytes) {
101
103
  return "light";
102
104
  }
103
105
 
106
+ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated") {
107
+ if (requestedMode === undefined) {
108
+ return existingMode;
109
+ }
110
+ if (requestedMode === "isolated" || requestedMode === "host-root") {
111
+ return requestedMode;
112
+ }
113
+ throw new Error("Install access mode must be isolated or host-root.");
114
+ }
115
+
104
116
  export async function inspectSystemResources(root) {
105
117
  validateHome(root);
106
118
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -138,11 +150,16 @@ export function installResourceChecks(resources) {
138
150
  ];
139
151
  }
140
152
 
141
- export function createDefaultConfig({ version, profile = "light" }) {
153
+ export function createDefaultConfig({
154
+ version,
155
+ profile = "light",
156
+ accessMode = "isolated"
157
+ }) {
142
158
  assertVersion(version);
143
159
  if (profile !== "light" && profile !== "standard") {
144
160
  throw new Error("Default config requires a resolved light or standard profile.");
145
161
  }
162
+ const resolvedAccessMode = resolveInstallAccessMode(accessMode);
146
163
  return {
147
164
  schemaVersion: CONFIG_SCHEMA_VERSION,
148
165
  version,
@@ -151,6 +168,7 @@ export function createDefaultConfig({ version, profile = "light" }) {
151
168
  port: 4911,
152
169
  telemetry: false,
153
170
  profile,
171
+ accessMode: resolvedAccessMode,
154
172
  workspaces: []
155
173
  };
156
174
  }
@@ -186,6 +204,9 @@ export function validateConfig(config) {
186
204
  if (!["light", "standard"].includes(config.profile)) {
187
205
  throw new Error("profile must be light or standard.");
188
206
  }
207
+ if (config.accessMode !== "isolated" && config.accessMode !== "host-root") {
208
+ throw new Error("accessMode must be isolated or host-root.");
209
+ }
189
210
  if (!Array.isArray(config.workspaces)) {
190
211
  throw new Error("workspaces must be an array.");
191
212
  }
@@ -209,6 +230,13 @@ export async function loadConfig(root) {
209
230
  }
210
231
 
211
232
  function migrateConfig(config) {
233
+ if (config?.schemaVersion === 2) {
234
+ return {
235
+ ...config,
236
+ schemaVersion: CONFIG_SCHEMA_VERSION,
237
+ accessMode: "isolated"
238
+ };
239
+ }
212
240
  if (config?.schemaVersion !== 1) {
213
241
  return config;
214
242
  }
@@ -219,7 +247,8 @@ function migrateConfig(config) {
219
247
  return {
220
248
  ...config,
221
249
  schemaVersion: CONFIG_SCHEMA_VERSION,
222
- profile: legacyProfiles[config.profile] ?? config.profile
250
+ profile: legacyProfiles[config.profile] ?? config.profile,
251
+ accessMode: "isolated"
223
252
  };
224
253
  }
225
254
 
@@ -351,6 +380,35 @@ export function renderWorkspaceCompose(config) {
351
380
  ].join("\n");
352
381
  }
353
382
 
383
+ export function renderHostAccessCompose(config) {
384
+ validateConfig(config);
385
+ if (config.accessMode === "isolated") {
386
+ return "services: {}\n";
387
+ }
388
+ const hostMount = (readOnly) => [
389
+ " - type: bind",
390
+ ' source: "/"',
391
+ ' target: "/host"',
392
+ ` read_only: ${readOnly}`,
393
+ " bind:",
394
+ " propagation: rslave"
395
+ ];
396
+ return [
397
+ "services:",
398
+ " spaceapp-core:",
399
+ " environment:",
400
+ ' SPACE_CLI_WORKSPACE_ROOT: "/host"',
401
+ " volumes:",
402
+ ...hostMount(true),
403
+ " spaceapp-cli:",
404
+ " environment:",
405
+ ' SPACEAPP_CLI_HOST_ROOT_ACCESS: "true"',
406
+ " volumes:",
407
+ ...hostMount(false),
408
+ ""
409
+ ].join("\n");
410
+ }
411
+
354
412
  export function composeCommand(action, root, options = {}) {
355
413
  validateHome(root);
356
414
  const stateRoot = options.stateRoot ?? root;
@@ -366,6 +424,7 @@ export function composeCommand(action, root, options = {}) {
366
424
  "--env-file", join(stateRoot, "runtime.env"),
367
425
  "-f", join(stateRoot, "compose.yml"),
368
426
  "-f", join(stateRoot, "compose.workspaces.yml"),
427
+ "-f", join(stateRoot, "compose.host-access.yml"),
369
428
  ...(profile === "standard" ? ["--profile", "standard"] : [])
370
429
  ];
371
430
  const actions = {
@@ -413,6 +472,7 @@ export function composeCommand(action, root, options = {}) {
413
472
  "scripts/rotate-owner-setup-token.mjs",
414
473
  "--stdin"
415
474
  ],
475
+ removeBrowser: ["rm", "--stop", "--force", "spaceapp-browser"],
416
476
  purge: ["down", "--volumes", "--remove-orphans"]
417
477
  };
418
478
  let selected = actions[action];
@@ -461,7 +521,7 @@ export async function selectLatestBackupId(root) {
461
521
  .at(-1);
462
522
  if (!backupId) {
463
523
  throw new Error(
464
- 'No portable backup exists. Run "npx --yes run-spaceapp@latest backup" before restore.'
524
+ `No portable backup exists. Run "${UNIVERSAL_COMMAND} backup" before restore.`
465
525
  );
466
526
  }
467
527
  return backupId;
@@ -473,11 +533,13 @@ export async function writeRuntimeFiles(root, config) {
473
533
  await mkdir(root, { recursive: true, mode: 0o700 });
474
534
  await atomicWrite(join(root, "runtime.env"), renderRuntimeEnv(config));
475
535
  await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config));
536
+ await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config));
476
537
  }
477
538
 
478
539
  export async function prepareInstallation(root, {
479
540
  version,
480
- profile
541
+ profile,
542
+ accessMode
481
543
  }) {
482
544
  validateHome(root);
483
545
  await mkdir(root, { recursive: true, mode: 0o700 });
@@ -495,7 +557,11 @@ export async function prepareInstallation(root, {
495
557
  if (error?.code !== "ENOENT") {
496
558
  throw error;
497
559
  }
498
- config = createDefaultConfig({ version, profile: resolvedProfile ?? "light" });
560
+ config = createDefaultConfig({
561
+ version,
562
+ profile: resolvedProfile ?? "light",
563
+ accessMode: resolveInstallAccessMode(accessMode)
564
+ });
499
565
  }
500
566
  if (config.version !== version) {
501
567
  config = {
@@ -507,6 +573,10 @@ export async function prepareInstallation(root, {
507
573
  if (resolvedProfile !== undefined) {
508
574
  config = { ...config, profile: resolvedProfile };
509
575
  }
576
+ const resolvedAccessMode = resolveInstallAccessMode(accessMode, config.accessMode);
577
+ if (config.accessMode !== resolvedAccessMode) {
578
+ config = { ...config, accessMode: resolvedAccessMode };
579
+ }
510
580
  validateConfig(config);
511
581
 
512
582
  const postgresPasswordPath = join(root, "secrets", "postgres-password");
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const manifest = JSON.parse(
4
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
5
+ );
6
+
7
+ export const PACKAGE_VERSION = manifest.version;
8
+ export const LAUNCHER_DIST_TAG = PACKAGE_VERSION.includes("-hostroot.")
9
+ ? "personal"
10
+ : "latest";
11
+ export const UNIVERSAL_COMMAND = `npx --yes run-spaceapp@${LAUNCHER_DIST_TAG}`;
12
+ export const UNIVERSAL_INSTALL_COMMAND = `${UNIVERSAL_COMMAND} install`;
@@ -8,10 +8,10 @@ import { Readable } from "node:stream";
8
8
  import { pipeline } from "node:stream/promises";
9
9
  import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { UNIVERSAL_INSTALL_COMMAND } from "./package-info.mjs";
11
12
 
12
13
  const DOCKER_TERMS_URL = "https://www.docker.com/legal/docker-subscription-service-agreement/";
13
14
  const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1_000;
14
- const UNIVERSAL_INSTALL_COMMAND = "npx --yes run-spaceapp@latest install";
15
15
  const WINDOWS_DOCKER_DOWNLOADS = Object.freeze({
16
16
  x64: "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe",
17
17
  arm64: "https://desktop.docker.com/win/main/arm64/Docker%20Desktop%20Installer.exe"
@@ -644,6 +644,10 @@ async function installLinuxDockerEngine({
644
644
  download
645
645
  }) {
646
646
  const id = String(release?.ID || "").toLowerCase();
647
+ const idFamily = new Set([
648
+ id,
649
+ ...String(release?.ID_LIKE || "").toLowerCase().split(/\s+/).filter(Boolean)
650
+ ]);
647
651
  if (id === "ubuntu" || id === "debian") {
648
652
  return installAptDockerEngine({
649
653
  arch,
@@ -659,6 +663,13 @@ async function installLinuxDockerEngine({
659
663
  if (id === "fedora" || id === "rhel" || id === "centos") {
660
664
  return installDnfDockerEngine({ id, stdin, stdout, stderr, execute });
661
665
  }
666
+ if (
667
+ idFamily.has("arch") ||
668
+ idFamily.has("archlinux") ||
669
+ idFamily.has("cachyos")
670
+ ) {
671
+ return installPacmanDockerEngine({ stdin, stdout, stderr, execute });
672
+ }
662
673
  stderr.write(
663
674
  `Automatic Docker Engine installation is not yet supported for Linux distribution "${id || "unknown"}". ` +
664
675
  "Install Docker Engine and the Compose plugin from https://docs.docker.com/engine/install/ and rerun the same command.\n"
@@ -666,6 +677,23 @@ async function installLinuxDockerEngine({
666
677
  return 1;
667
678
  }
668
679
 
680
+ function installPacmanDockerEngine({ stdin, stdout, stderr, execute }) {
681
+ return execute(
682
+ {
683
+ command: "sudo",
684
+ args: [
685
+ "pacman",
686
+ "-S",
687
+ "--needed",
688
+ "--noconfirm",
689
+ "docker",
690
+ "docker-compose"
691
+ ]
692
+ },
693
+ { stdin, stdout, stderr }
694
+ );
695
+ }
696
+
669
697
  async function installAptDockerEngine({
670
698
  arch,
671
699
  id,
@@ -786,7 +814,11 @@ async function readLinuxOsRelease() {
786
814
  return values;
787
815
  }
788
816
 
789
- function linuxReentryCommand({ requestedProfile = "auto", noOpen = false } = {}) {
817
+ function linuxReentryCommand({
818
+ requestedProfile = "auto",
819
+ requestedAccessMode,
820
+ noOpen = false
821
+ } = {}) {
790
822
  if (!["auto", "light", "standard"].includes(requestedProfile)) {
791
823
  throw new Error("Invalid SpaceApp profile for Docker group re-entry.");
792
824
  }
@@ -799,6 +831,7 @@ function linuxReentryCommand({ requestedProfile = "auto", noOpen = false } = {})
799
831
  "install",
800
832
  "--profile",
801
833
  requestedProfile,
834
+ ...installAccessArgs(requestedAccessMode, "Docker group re-entry"),
802
835
  ...(noOpen ? ["--no-open"] : [])
803
836
  ].map(shellQuote).join(" ");
804
837
  }
@@ -807,6 +840,7 @@ export function windowsResumeScript({
807
840
  executable = process.execPath,
808
841
  entrypoint = fileURLToPath(new URL("../bin/spaceapp.mjs", import.meta.url)),
809
842
  requestedProfile = "auto",
843
+ requestedAccessMode,
810
844
  noOpen = false
811
845
  } = {}) {
812
846
  if (!["auto", "light", "standard"].includes(requestedProfile)) {
@@ -830,6 +864,7 @@ export function windowsResumeScript({
830
864
  "install",
831
865
  "--profile",
832
866
  requestedProfile,
867
+ ...installAccessArgs(requestedAccessMode, "Windows resume"),
833
868
  ...(noOpen ? ["--no-open"] : [])
834
869
  ].join(" ");
835
870
  return [
@@ -847,6 +882,7 @@ export function windowsResumeScript({
847
882
  async function scheduleWindowsInstallResume({
848
883
  root,
849
884
  requestedProfile = "auto",
885
+ requestedAccessMode,
850
886
  noOpen = false,
851
887
  execute,
852
888
  stdin,
@@ -865,7 +901,7 @@ async function scheduleWindowsInstallResume({
865
901
  await mkdir(root, { recursive: true, mode: 0o700 });
866
902
  await writeFile(
867
903
  resumePath,
868
- windowsResumeScript({ requestedProfile, noOpen }),
904
+ windowsResumeScript({ requestedProfile, requestedAccessMode, noOpen }),
869
905
  { encoding: "utf8", mode: 0o600 }
870
906
  );
871
907
  const code = await execute(
@@ -882,6 +918,16 @@ async function scheduleWindowsInstallResume({
882
918
  return code;
883
919
  }
884
920
 
921
+ function installAccessArgs(requestedAccessMode, context) {
922
+ if (requestedAccessMode === undefined) {
923
+ return [];
924
+ }
925
+ if (requestedAccessMode === "isolated" || requestedAccessMode === "host-root") {
926
+ return ["--access", requestedAccessMode];
927
+ }
928
+ throw new Error(`Invalid SpaceApp access mode for ${context}.`);
929
+ }
930
+
885
931
  function shellQuote(value) {
886
932
  return `'${String(value).replaceAll("'", "'\\''")}'`;
887
933
  }