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 +31 -9
- package/package.json +3 -3
- package/src/cli.mjs +165 -22
- package/src/index.mjs +76 -6
- package/src/package-info.mjs +12 -0
- package/src/prerequisites.mjs +49 -3
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,
|
|
23
|
-
|
|
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@
|
|
84
|
+
npx --yes run-spaceapp@personal provider install claude
|
|
63
85
|
```
|
|
64
86
|
|
|
65
|
-
Run `npx --yes run-spaceapp@
|
|
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@
|
|
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@
|
|
85
|
-
npx --yes run-spaceapp@
|
|
86
|
-
npx --yes run-spaceapp@
|
|
87
|
-
npx --yes run-spaceapp@
|
|
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.
|
|
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": "
|
|
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 =
|
|
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
|
-
|
|
279
|
-
|
|
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 (
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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`;
|
package/src/prerequisites.mjs
CHANGED
|
@@ -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({
|
|
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
|
}
|