run-spaceapp 0.1.14 → 0.1.15-hostroot.1
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 +28 -11
- package/package.json +5 -3
- package/src/cli.mjs +310 -53
- package/src/index.mjs +79 -10
- package/src/package-info.mjs +32 -0
- package/src/prerequisites.mjs +78 -19
package/README.md
CHANGED
|
@@ -19,12 +19,14 @@ 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
|
-
acceptance
|
|
27
|
-
|
|
27
|
+
acceptance still requires confirmation. On Linux, the same command explains
|
|
28
|
+
the root-equivalent `docker` group and adds the current user automatically.
|
|
29
|
+
On Windows, approve any UAC prompt required by WSL2 or Docker
|
|
28
30
|
Desktop. Use the **Command Prompt** profile because a restricted PowerShell
|
|
29
31
|
policy can block npm's `npx.ps1` before SpaceApp starts. On Docker Desktop's
|
|
30
32
|
first launch, select **Skip** in the top-right of the **Welcome to Docker**
|
|
@@ -50,6 +52,21 @@ current user's platform config directory:
|
|
|
50
52
|
|
|
51
53
|
Set `SPACEAPP_HOME` to an absolute path to use a dedicated installation root.
|
|
52
54
|
|
|
55
|
+
The `0.1.15-hostroot.1` personal candidate tests installer fixes while reusing
|
|
56
|
+
the existing `0.1.15-hostroot.0` runtime images:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx --yes run-spaceapp@personal install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This launcher-only candidate accepts isolated access on Linux, macOS, and
|
|
63
|
+
Windows. It rejects `--access host-root` because no matching runtime images
|
|
64
|
+
were rebuilt. The launcher reports version `.1` while `runtime.env` remains
|
|
65
|
+
pinned to `.0`.
|
|
66
|
+
|
|
67
|
+
Use the same `npx --yes run-spaceapp@personal` prefix for follow-up commands
|
|
68
|
+
while this prerelease is installed.
|
|
69
|
+
|
|
53
70
|
Provider API keys are read from masked standard input and written to
|
|
54
71
|
restrictive files outside Git. OAuth and device-code credentials remain in
|
|
55
72
|
isolated Docker provider state. Neither credential source is included in
|
|
@@ -59,10 +76,10 @@ Claude Code is not redistributed in SpaceApp images. Install the reviewed
|
|
|
59
76
|
package as an explicit owner action with:
|
|
60
77
|
|
|
61
78
|
```bash
|
|
62
|
-
npx --yes run-spaceapp@
|
|
79
|
+
npx --yes run-spaceapp@personal provider install claude
|
|
63
80
|
```
|
|
64
81
|
|
|
65
|
-
Run `npx --yes run-spaceapp@
|
|
82
|
+
Run `npx --yes run-spaceapp@personal help` for the complete command list. Full
|
|
66
83
|
documentation:
|
|
67
84
|
|
|
68
85
|
- [Getting started](https://github.com/oll4com/spaceapp/blob/main/docs/getting-started.md)
|
|
@@ -75,16 +92,16 @@ After the stack passes readiness checks, the install command prints the fresh
|
|
|
75
92
|
it expires before the owner is created, run:
|
|
76
93
|
|
|
77
94
|
```bash
|
|
78
|
-
npx --yes run-spaceapp@
|
|
95
|
+
npx --yes run-spaceapp@personal owner rotate-setup-token
|
|
79
96
|
```
|
|
80
97
|
|
|
81
98
|
Common follow-up commands use the same reliable launcher prefix:
|
|
82
99
|
|
|
83
100
|
```bash
|
|
84
|
-
npx --yes run-spaceapp@
|
|
85
|
-
npx --yes run-spaceapp@
|
|
86
|
-
npx --yes run-spaceapp@
|
|
87
|
-
npx --yes run-spaceapp@
|
|
101
|
+
npx --yes run-spaceapp@personal doctor
|
|
102
|
+
npx --yes run-spaceapp@personal status
|
|
103
|
+
npx --yes run-spaceapp@personal credentials list
|
|
104
|
+
npx --yes run-spaceapp@personal uninstall
|
|
88
105
|
```
|
|
89
106
|
|
|
90
107
|
Uninstall retains data by default and prints the separate
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-spaceapp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15-hostroot.1",
|
|
4
|
+
"spaceappRuntimeVersion": "0.1.15-hostroot.0",
|
|
5
|
+
"spaceappHostRootRuntimeCompatible": false,
|
|
4
6
|
"description": "Cross-platform Docker launcher for the SpaceApp self-hosted agent workspace",
|
|
5
7
|
"license": "Apache-2.0",
|
|
6
8
|
"type": "module",
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
],
|
|
31
33
|
"scripts": {
|
|
32
34
|
"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",
|
|
35
|
+
"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
36
|
"pack:dry-run": "npm pack --dry-run"
|
|
35
37
|
},
|
|
36
38
|
"repository": {
|
|
@@ -46,5 +48,5 @@
|
|
|
46
48
|
"access": "public",
|
|
47
49
|
"provenance": true
|
|
48
50
|
},
|
|
49
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "7e0543f784e614cd588bf54cd3228de3912d03d6"
|
|
50
52
|
}
|
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,20 @@ import {
|
|
|
29
30
|
prepareDockerCliPath,
|
|
30
31
|
windowsPowerShellArgs
|
|
31
32
|
} from "./prerequisites.mjs";
|
|
33
|
+
import {
|
|
34
|
+
HOST_ROOT_RUNTIME_COMPATIBLE,
|
|
35
|
+
PACKAGE_VERSION,
|
|
36
|
+
RUNTIME_VERSION,
|
|
37
|
+
UNIVERSAL_COMMAND
|
|
38
|
+
} from "./package-info.mjs";
|
|
32
39
|
|
|
33
|
-
const
|
|
40
|
+
const APPLICATION_READY_WAIT_MINUTES = 10;
|
|
41
|
+
const APPLICATION_READY_WAIT_MS = APPLICATION_READY_WAIT_MINUTES * 60 * 1_000;
|
|
34
42
|
const APPLICATION_READY_POLL_MS = 2_000;
|
|
35
43
|
const APPLICATION_READY_MAX_ATTEMPTS = APPLICATION_READY_WAIT_MS / APPLICATION_READY_POLL_MS;
|
|
44
|
+
const APPLICATION_READY_PROGRESS_ATTEMPTS = 30_000 / APPLICATION_READY_POLL_MS;
|
|
45
|
+
const APPLICATION_READY_LOG_INTERVAL_SECONDS = 120;
|
|
36
46
|
const SETUP_STATUS_TIMEOUT_MS = 10_000;
|
|
37
|
-
const UNIVERSAL_COMMAND = "npx --yes run-spaceapp@latest";
|
|
38
47
|
const trustedCommands = new Set([
|
|
39
48
|
"codesign",
|
|
40
49
|
"docker",
|
|
@@ -68,25 +77,31 @@ export async function run(argv, {
|
|
|
68
77
|
prepareDockerPath = prepareDockerCliPath,
|
|
69
78
|
request = globalThis.fetch,
|
|
70
79
|
sleep = wait,
|
|
71
|
-
persistSetupToken = writeSetupToken
|
|
80
|
+
persistSetupToken = writeSetupToken,
|
|
81
|
+
launcherVersion = PACKAGE_VERSION,
|
|
82
|
+
runtimeVersion = RUNTIME_VERSION,
|
|
83
|
+
hostRootRuntimeCompatible = HOST_ROOT_RUNTIME_COMPATIBLE
|
|
72
84
|
} = {}) {
|
|
73
|
-
await prepareDockerPath({ platform, env });
|
|
74
85
|
const [command = "help", ...args] = argv;
|
|
75
86
|
const root = resolveSpaceAppHome({ env, platform });
|
|
76
|
-
const version = await packageVersion();
|
|
77
87
|
|
|
88
|
+
if (command !== "install") {
|
|
89
|
+
await prepareDockerPath({ platform, env });
|
|
90
|
+
}
|
|
78
91
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
79
92
|
stdout.write(helpText());
|
|
80
93
|
return 0;
|
|
81
94
|
}
|
|
82
95
|
if (command === "--version" || command === "-v") {
|
|
83
|
-
stdout.write(`${
|
|
96
|
+
stdout.write(`${launcherVersion}\n`);
|
|
84
97
|
return 0;
|
|
85
98
|
}
|
|
86
99
|
if (command === "install") {
|
|
87
100
|
return installCommand(args, {
|
|
88
101
|
root,
|
|
89
|
-
|
|
102
|
+
launcherVersion,
|
|
103
|
+
runtimeVersion,
|
|
104
|
+
hostRootRuntimeCompatible,
|
|
90
105
|
platform,
|
|
91
106
|
arch,
|
|
92
107
|
env,
|
|
@@ -96,6 +111,7 @@ export async function run(argv, {
|
|
|
96
111
|
execute,
|
|
97
112
|
inspectResources,
|
|
98
113
|
ensureDocker,
|
|
114
|
+
prepareDockerPath,
|
|
99
115
|
request,
|
|
100
116
|
sleep,
|
|
101
117
|
persistSetupToken
|
|
@@ -103,7 +119,7 @@ export async function run(argv, {
|
|
|
103
119
|
}
|
|
104
120
|
if (command === "init") {
|
|
105
121
|
assertNoArgs(args, "init");
|
|
106
|
-
const result = await initializeInstallation(root, { version });
|
|
122
|
+
const result = await initializeInstallation(root, { version: runtimeVersion });
|
|
107
123
|
stdout.write(`SpaceApp initialized at ${root}\n`);
|
|
108
124
|
if (result.setupToken) {
|
|
109
125
|
stdout.write(`One-time setup token: ${result.setupToken}\n`);
|
|
@@ -156,7 +172,15 @@ export async function run(argv, {
|
|
|
156
172
|
return ownerCommand(args, { root, config, stdin, stdout, stderr, execute: runtimeExecute });
|
|
157
173
|
}
|
|
158
174
|
if (command === "update") {
|
|
159
|
-
return updateCommand(args, {
|
|
175
|
+
return updateCommand(args, {
|
|
176
|
+
root,
|
|
177
|
+
config,
|
|
178
|
+
version: runtimeVersion,
|
|
179
|
+
stdin,
|
|
180
|
+
stdout,
|
|
181
|
+
stderr,
|
|
182
|
+
execute: runtimeExecute
|
|
183
|
+
});
|
|
160
184
|
}
|
|
161
185
|
if (command === "rollback") {
|
|
162
186
|
assertNoArgs(args, "rollback");
|
|
@@ -258,7 +282,9 @@ export async function run(argv, {
|
|
|
258
282
|
|
|
259
283
|
async function installCommand(args, {
|
|
260
284
|
root,
|
|
261
|
-
|
|
285
|
+
launcherVersion,
|
|
286
|
+
runtimeVersion,
|
|
287
|
+
hostRootRuntimeCompatible,
|
|
262
288
|
platform,
|
|
263
289
|
arch,
|
|
264
290
|
env,
|
|
@@ -268,28 +294,54 @@ async function installCommand(args, {
|
|
|
268
294
|
execute,
|
|
269
295
|
inspectResources,
|
|
270
296
|
ensureDocker,
|
|
297
|
+
prepareDockerPath,
|
|
271
298
|
request,
|
|
272
299
|
sleep,
|
|
273
300
|
persistSetupToken
|
|
274
301
|
}) {
|
|
275
|
-
const { requestedProfile, noOpen } = parseInstallArgs(args);
|
|
302
|
+
const { requestedProfile, requestedAccessMode, noOpen } = parseInstallArgs(args);
|
|
303
|
+
const existingConfig = await loadExistingInstallation(root);
|
|
304
|
+
const accessMode = resolveInstallAccessMode(
|
|
305
|
+
requestedAccessMode,
|
|
306
|
+
existingConfig?.accessMode ?? "isolated"
|
|
307
|
+
);
|
|
308
|
+
if (accessMode === "host-root" && platform !== "linux") {
|
|
309
|
+
throw new Error("Host-root access is supported only on Linux.");
|
|
310
|
+
}
|
|
311
|
+
if (accessMode === "host-root" && !hostRootRuntimeCompatible) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"Host-root access is disabled for this launcher-only candidate because its runtime images were not rebuilt. Use --access isolated."
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
await prepareDockerPath({ platform, env });
|
|
276
317
|
const resources = await inspectResources(root);
|
|
277
318
|
const profile = resolveInstallProfile(requestedProfile, resources.totalMemoryBytes);
|
|
278
|
-
|
|
279
|
-
|
|
319
|
+
if (accessMode === "host-root") {
|
|
320
|
+
stderr.write(
|
|
321
|
+
"WARNING: host-root access lets SpaceApp CLI sessions read and modify the entire Linux host through /host, including credentials and system files.\n"
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const result = await prepareInstallation(root, {
|
|
325
|
+
version: runtimeVersion,
|
|
326
|
+
profile,
|
|
327
|
+
accessMode
|
|
328
|
+
});
|
|
280
329
|
|
|
281
|
-
stdout.write(`Launcher version: ${
|
|
330
|
+
stdout.write(`Launcher version: ${launcherVersion}\n`);
|
|
282
331
|
stdout.write(
|
|
283
|
-
`
|
|
332
|
+
`Runtime image version: ${existingConfig?.version ?? "not installed"} -> ${result.config.version}\n`
|
|
284
333
|
);
|
|
285
334
|
stdout.write(
|
|
286
335
|
`Profile: ${existingConfig?.profile ?? "not configured"} -> ${result.config.profile}\n`
|
|
287
336
|
);
|
|
337
|
+
stdout.write(
|
|
338
|
+
`Access: ${existingConfig?.accessMode ?? "not configured"} -> ${result.config.accessMode}\n`
|
|
339
|
+
);
|
|
288
340
|
stdout.write(existingConfig
|
|
289
341
|
? "Preserved existing data, workspaces, credentials, secrets, and persistent Docker volumes.\n"
|
|
290
342
|
: "Future refreshes preserve data, workspaces, credentials, secrets, and persistent Docker volumes.\n");
|
|
291
343
|
stdout.write(
|
|
292
|
-
`Selected profile: ${profile} (${
|
|
344
|
+
`Selected profile: ${profile} (${formatGibibytes(resources.totalMemoryBytes)} GiB system memory detected).\n`
|
|
293
345
|
);
|
|
294
346
|
stdout.write(`SpaceApp installation root: ${root}\n`);
|
|
295
347
|
if (installResourceChecks(resources).some((check) => !check.ok)) {
|
|
@@ -317,14 +369,15 @@ async function installCommand(args, {
|
|
|
317
369
|
stdout,
|
|
318
370
|
stderr,
|
|
319
371
|
execute,
|
|
320
|
-
installArgs: { root, requestedProfile, noOpen }
|
|
372
|
+
installArgs: { root, requestedProfile, requestedAccessMode, noOpen }
|
|
321
373
|
});
|
|
322
|
-
if (prerequisiteResult.reexecuted
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
374
|
+
if (prerequisiteResult.reexecuted) {
|
|
375
|
+
return prerequisiteResult.code;
|
|
376
|
+
}
|
|
377
|
+
if (prerequisiteResult.code !== 0) {
|
|
378
|
+
stderr.write(
|
|
379
|
+
`Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n`
|
|
380
|
+
);
|
|
328
381
|
return prerequisiteResult.code;
|
|
329
382
|
}
|
|
330
383
|
|
|
@@ -346,10 +399,42 @@ async function installCommand(args, {
|
|
|
346
399
|
return doctorCode;
|
|
347
400
|
}
|
|
348
401
|
const stagedStateRoot = await mkdtemp(join(tmpdir(), "run-spaceapp-install-"));
|
|
402
|
+
let runtimeMutationAttempted = false;
|
|
403
|
+
const failAfterRuntimeMutation = async (code) => {
|
|
404
|
+
if (runtimeMutationAttempted) {
|
|
405
|
+
await reportInstallDiagnostics({
|
|
406
|
+
root,
|
|
407
|
+
stateRoot: stagedStateRoot,
|
|
408
|
+
profile,
|
|
409
|
+
platform,
|
|
410
|
+
stdin,
|
|
411
|
+
stdout,
|
|
412
|
+
stderr,
|
|
413
|
+
execute
|
|
414
|
+
});
|
|
415
|
+
await restoreRuntimeAfterFailedInstall({
|
|
416
|
+
root,
|
|
417
|
+
stagedStateRoot,
|
|
418
|
+
existingConfig,
|
|
419
|
+
attemptedProfile: profile,
|
|
420
|
+
platform,
|
|
421
|
+
stdin,
|
|
422
|
+
stdout,
|
|
423
|
+
stderr,
|
|
424
|
+
execute
|
|
425
|
+
});
|
|
426
|
+
runtimeMutationAttempted = false;
|
|
427
|
+
}
|
|
428
|
+
return code;
|
|
429
|
+
};
|
|
349
430
|
try {
|
|
350
431
|
await commitInstallation(stagedStateRoot, result.config);
|
|
351
|
-
const stagedComposeCommand = (action) =>
|
|
352
|
-
composeCommand(action, root, {
|
|
432
|
+
const stagedComposeCommand = (action, options = {}) =>
|
|
433
|
+
composeCommand(action, root, {
|
|
434
|
+
profile,
|
|
435
|
+
stateRoot: stagedStateRoot,
|
|
436
|
+
...options
|
|
437
|
+
});
|
|
353
438
|
const pullCode = await executeWithDockerDiagnostics(
|
|
354
439
|
execute,
|
|
355
440
|
stagedComposeCommand("pull"),
|
|
@@ -357,27 +442,72 @@ async function installCommand(args, {
|
|
|
357
442
|
{ platform, stderr }
|
|
358
443
|
);
|
|
359
444
|
if (pullCode !== 0) return pullCode;
|
|
445
|
+
runtimeMutationAttempted = true;
|
|
360
446
|
const upCode = await executeWithDockerDiagnostics(
|
|
361
447
|
execute,
|
|
362
448
|
stagedComposeCommand("up"),
|
|
363
449
|
{ stdin, stdout, stderr },
|
|
364
450
|
{ platform, stderr }
|
|
365
451
|
);
|
|
366
|
-
if (upCode !== 0) return upCode;
|
|
452
|
+
if (upCode !== 0) return await failAfterRuntimeMutation(upCode);
|
|
367
453
|
|
|
368
454
|
const url = `http://${result.config.bindHost}:${result.config.port}`;
|
|
369
|
-
stdout.write(
|
|
455
|
+
stdout.write(
|
|
456
|
+
`Waiting up to ${APPLICATION_READY_WAIT_MINUTES} minutes for SpaceApp services to become ready...\n`
|
|
457
|
+
);
|
|
370
458
|
const ready = await waitForApplicationReady({
|
|
371
459
|
url,
|
|
372
460
|
request,
|
|
373
|
-
sleep
|
|
461
|
+
sleep,
|
|
462
|
+
onProgress: async ({ elapsedSeconds }) => {
|
|
463
|
+
stdout.write(
|
|
464
|
+
`Still waiting for SpaceApp services (${elapsedSeconds} seconds elapsed; ` +
|
|
465
|
+
`up to ${APPLICATION_READY_WAIT_MINUTES} minutes)...\n`
|
|
466
|
+
);
|
|
467
|
+
try {
|
|
468
|
+
const statusCode = await executeWithDockerDiagnostics(
|
|
469
|
+
execute,
|
|
470
|
+
stagedComposeCommand("status"),
|
|
471
|
+
{ stdin, stdout, stderr },
|
|
472
|
+
{ platform, stderr }
|
|
473
|
+
);
|
|
474
|
+
if (statusCode !== 0) {
|
|
475
|
+
stderr.write(`SpaceApp service status check failed with Docker exit ${statusCode}.\n`);
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
stderr.write(
|
|
479
|
+
`SpaceApp could not collect status diagnostics: ${error?.message || String(error)}.\n`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
if (
|
|
483
|
+
elapsedSeconds % APPLICATION_READY_LOG_INTERVAL_SECONDS === 0 &&
|
|
484
|
+
elapsedSeconds < APPLICATION_READY_WAIT_MINUTES * 60
|
|
485
|
+
) {
|
|
486
|
+
stdout.write(`Showing recent startup logs after ${elapsedSeconds} seconds...\n`);
|
|
487
|
+
try {
|
|
488
|
+
const logsCode = await executeWithDockerDiagnostics(
|
|
489
|
+
execute,
|
|
490
|
+
stagedComposeCommand("logs", { lines: 50 }),
|
|
491
|
+
{ stdin, stdout, stderr },
|
|
492
|
+
{ platform, stderr }
|
|
493
|
+
);
|
|
494
|
+
if (logsCode !== 0) {
|
|
495
|
+
stderr.write(`SpaceApp startup log check failed with Docker exit ${logsCode}.\n`);
|
|
496
|
+
}
|
|
497
|
+
} catch (error) {
|
|
498
|
+
stderr.write(
|
|
499
|
+
`SpaceApp could not collect logs diagnostics: ${error?.message || String(error)}.\n`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
374
504
|
});
|
|
375
505
|
if (!ready) {
|
|
376
506
|
stderr.write(
|
|
377
|
-
|
|
378
|
-
|
|
507
|
+
`SpaceApp containers started, but the application did not become ready within ` +
|
|
508
|
+
`${APPLICATION_READY_WAIT_MINUTES} minutes.\n`
|
|
379
509
|
);
|
|
380
|
-
return 1;
|
|
510
|
+
return await failAfterRuntimeMutation(1);
|
|
381
511
|
}
|
|
382
512
|
|
|
383
513
|
let setupStatus;
|
|
@@ -388,7 +518,7 @@ async function installCommand(args, {
|
|
|
388
518
|
`SpaceApp is ready, but owner setup status could not be verified: ${error?.message || String(error)}\n` +
|
|
389
519
|
`Run "${UNIVERSAL_COMMAND} status" and "${UNIVERSAL_COMMAND} logs", then run "${UNIVERSAL_COMMAND} install" again.\n`
|
|
390
520
|
);
|
|
391
|
-
return 1;
|
|
521
|
+
return await failAfterRuntimeMutation(1);
|
|
392
522
|
}
|
|
393
523
|
|
|
394
524
|
let setupToken = null;
|
|
@@ -409,7 +539,7 @@ async function installCommand(args, {
|
|
|
409
539
|
stderr.write(
|
|
410
540
|
`SpaceApp is ready, but a fresh owner setup token could not be created. Run "${UNIVERSAL_COMMAND} owner rotate-setup-token".\n`
|
|
411
541
|
);
|
|
412
|
-
return rotateCode;
|
|
542
|
+
return await failAfterRuntimeMutation(rotateCode);
|
|
413
543
|
}
|
|
414
544
|
}
|
|
415
545
|
|
|
@@ -427,7 +557,7 @@ async function installCommand(args, {
|
|
|
427
557
|
stderr.write(
|
|
428
558
|
"SpaceApp is ready, but the managed browser container could not be removed for the light profile.\n"
|
|
429
559
|
);
|
|
430
|
-
return removeBrowserCode;
|
|
560
|
+
return await failAfterRuntimeMutation(removeBrowserCode);
|
|
431
561
|
}
|
|
432
562
|
}
|
|
433
563
|
|
|
@@ -439,11 +569,12 @@ async function installCommand(args, {
|
|
|
439
569
|
"SpaceApp accepted a new setup token, but it could not be saved locally.\n" +
|
|
440
570
|
`Run "${UNIVERSAL_COMMAND} owner rotate-setup-token" to obtain a usable token.\n`
|
|
441
571
|
);
|
|
442
|
-
return 1;
|
|
572
|
+
return await failAfterRuntimeMutation(1);
|
|
443
573
|
}
|
|
444
574
|
}
|
|
445
575
|
|
|
446
576
|
await commitInstallation(root, result.config);
|
|
577
|
+
runtimeMutationAttempted = false;
|
|
447
578
|
stdout.write(`SpaceApp is ready at ${url}\n`);
|
|
448
579
|
if (setupToken) {
|
|
449
580
|
stdout.write(`One-time setup token: ${setupToken}\n`);
|
|
@@ -460,13 +591,117 @@ async function installCommand(args, {
|
|
|
460
591
|
stderr.write(`Could not open SpaceApp automatically. Open ${url} manually.\n`);
|
|
461
592
|
}
|
|
462
593
|
return 0;
|
|
594
|
+
} catch (error) {
|
|
595
|
+
await failAfterRuntimeMutation(1);
|
|
596
|
+
throw error;
|
|
463
597
|
} finally {
|
|
464
598
|
await rm(stagedStateRoot, { recursive: true, force: true });
|
|
465
599
|
}
|
|
466
600
|
}
|
|
467
601
|
|
|
602
|
+
async function reportInstallDiagnostics({
|
|
603
|
+
root,
|
|
604
|
+
stateRoot,
|
|
605
|
+
profile,
|
|
606
|
+
platform,
|
|
607
|
+
stdin,
|
|
608
|
+
stdout,
|
|
609
|
+
stderr,
|
|
610
|
+
execute
|
|
611
|
+
}) {
|
|
612
|
+
stderr.write("Collecting SpaceApp service status and recent logs before rollback...\n");
|
|
613
|
+
for (const action of ["status", "logs"]) {
|
|
614
|
+
try {
|
|
615
|
+
const code = await executeWithDockerDiagnostics(
|
|
616
|
+
execute,
|
|
617
|
+
composeCommand(action, root, {
|
|
618
|
+
profile,
|
|
619
|
+
stateRoot,
|
|
620
|
+
lines: 200
|
|
621
|
+
}),
|
|
622
|
+
{ stdin, stdout, stderr },
|
|
623
|
+
{ platform, stderr }
|
|
624
|
+
);
|
|
625
|
+
if (code !== 0) {
|
|
626
|
+
stderr.write(
|
|
627
|
+
`SpaceApp could not collect ${action} diagnostics (Docker exit ${code}).\n`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
} catch (error) {
|
|
631
|
+
stderr.write(
|
|
632
|
+
`SpaceApp could not collect ${action} diagnostics: ${error?.message || String(error)}.\n`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function restoreRuntimeAfterFailedInstall({
|
|
639
|
+
root,
|
|
640
|
+
stagedStateRoot,
|
|
641
|
+
existingConfig,
|
|
642
|
+
attemptedProfile,
|
|
643
|
+
platform,
|
|
644
|
+
stdin,
|
|
645
|
+
stdout,
|
|
646
|
+
stderr,
|
|
647
|
+
execute
|
|
648
|
+
}) {
|
|
649
|
+
const runtimeExecute = (spec) => executeWithDockerDiagnostics(
|
|
650
|
+
execute,
|
|
651
|
+
spec,
|
|
652
|
+
{ stdin, stdout, stderr },
|
|
653
|
+
{ platform, stderr }
|
|
654
|
+
);
|
|
655
|
+
try {
|
|
656
|
+
if (existingConfig) {
|
|
657
|
+
await commitInstallation(root, existingConfig);
|
|
658
|
+
const restoreCode = await runtimeExecute(
|
|
659
|
+
composeCommand("up", root, {
|
|
660
|
+
profile: existingConfig.profile
|
|
661
|
+
})
|
|
662
|
+
);
|
|
663
|
+
if (restoreCode === 0) {
|
|
664
|
+
if (existingConfig.profile === "light") {
|
|
665
|
+
const cleanupCode = await runtimeExecute(
|
|
666
|
+
composeCommand("removeBrowser", root, {
|
|
667
|
+
profile: "standard"
|
|
668
|
+
})
|
|
669
|
+
);
|
|
670
|
+
if (cleanupCode !== 0) {
|
|
671
|
+
stderr.write(
|
|
672
|
+
"The previous SpaceApp runtime was restored, but an inactive browser container may remain.\n"
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
stderr.write("The previous SpaceApp runtime and access mode were restored.\n");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
stderr.write(
|
|
680
|
+
"The previous SpaceApp runtime could not be restored; stopping the partially updated runtime.\n"
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const stopCode = await runtimeExecute(
|
|
685
|
+
composeCommand("down", root, {
|
|
686
|
+
profile: existingConfig?.profile ?? attemptedProfile,
|
|
687
|
+
stateRoot: existingConfig ? root : stagedStateRoot
|
|
688
|
+
})
|
|
689
|
+
);
|
|
690
|
+
if (stopCode !== 0) {
|
|
691
|
+
stderr.write(
|
|
692
|
+
"The partially updated SpaceApp runtime could not be stopped automatically. Run the install command again immediately.\n"
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
} catch (error) {
|
|
696
|
+
stderr.write(
|
|
697
|
+
`SpaceApp could not restore or stop the partially updated runtime: ${error?.message || String(error)}\n`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
468
702
|
function parseInstallArgs(args) {
|
|
469
703
|
let requestedProfile = "auto";
|
|
704
|
+
let requestedAccessMode;
|
|
470
705
|
let noOpen = false;
|
|
471
706
|
for (let index = 0; index < args.length; index += 1) {
|
|
472
707
|
const argument = args[index];
|
|
@@ -483,16 +718,31 @@ function parseInstallArgs(args) {
|
|
|
483
718
|
requestedProfile = argument.slice("--profile=".length);
|
|
484
719
|
continue;
|
|
485
720
|
}
|
|
721
|
+
if (argument === "--access" && index + 1 < args.length) {
|
|
722
|
+
requestedAccessMode = args[index + 1];
|
|
723
|
+
index += 1;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (argument.startsWith("--access=")) {
|
|
727
|
+
requestedAccessMode = argument.slice("--access=".length);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
486
730
|
throw new Error(
|
|
487
|
-
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
|
|
731
|
+
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]`
|
|
488
732
|
);
|
|
489
733
|
}
|
|
490
|
-
if (
|
|
734
|
+
if (
|
|
735
|
+
!["auto", "light", "standard"].includes(requestedProfile) ||
|
|
736
|
+
(
|
|
737
|
+
requestedAccessMode !== undefined &&
|
|
738
|
+
!["isolated", "host-root"].includes(requestedAccessMode)
|
|
739
|
+
)
|
|
740
|
+
) {
|
|
491
741
|
throw new Error(
|
|
492
|
-
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--no-open]`
|
|
742
|
+
`Usage: ${UNIVERSAL_COMMAND} install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]`
|
|
493
743
|
);
|
|
494
744
|
}
|
|
495
|
-
return { requestedProfile, noOpen };
|
|
745
|
+
return { requestedProfile, requestedAccessMode, noOpen };
|
|
496
746
|
}
|
|
497
747
|
|
|
498
748
|
async function loadExistingInstallation(root) {
|
|
@@ -612,8 +862,8 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute
|
|
|
612
862
|
);
|
|
613
863
|
}
|
|
614
864
|
const password = await readSecret(stdin, stdout, "New owner password: ");
|
|
615
|
-
if (password.length <
|
|
616
|
-
throw new Error("Owner password must be at least
|
|
865
|
+
if (password.length < 6) {
|
|
866
|
+
throw new Error("Owner password must be at least 6 characters.");
|
|
617
867
|
}
|
|
618
868
|
return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile }), {
|
|
619
869
|
stdin,
|
|
@@ -628,11 +878,13 @@ async function updateCommand(args, { root, config, version, stdin, stdout, stder
|
|
|
628
878
|
throw new Error(`Usage: ${UNIVERSAL_COMMAND} update [version]`);
|
|
629
879
|
}
|
|
630
880
|
const targetVersion = args[0] || version;
|
|
631
|
-
const updated =
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
881
|
+
const updated = targetVersion === config.version
|
|
882
|
+
? config
|
|
883
|
+
: {
|
|
884
|
+
...config,
|
|
885
|
+
version: targetVersion,
|
|
886
|
+
previousVersion: config.version
|
|
887
|
+
};
|
|
636
888
|
await writeRuntimeFiles(root, updated);
|
|
637
889
|
const pullCode = await execute(composeCommand("pull", root, { profile: updated.profile }), { stdin, stdout, stderr });
|
|
638
890
|
if (pullCode !== 0) {
|
|
@@ -720,6 +972,7 @@ async function waitForApplicationReady({
|
|
|
720
972
|
url,
|
|
721
973
|
request,
|
|
722
974
|
sleep,
|
|
975
|
+
onProgress,
|
|
723
976
|
maxAttempts = APPLICATION_READY_MAX_ATTEMPTS
|
|
724
977
|
}) {
|
|
725
978
|
if (typeof request !== "function") {
|
|
@@ -749,6 +1002,15 @@ async function waitForApplicationReady({
|
|
|
749
1002
|
}
|
|
750
1003
|
if (attempt < maxAttempts && !controller.signal.aborted) {
|
|
751
1004
|
await sleep(APPLICATION_READY_POLL_MS);
|
|
1005
|
+
const completedAttempts = attempt + 1;
|
|
1006
|
+
if (
|
|
1007
|
+
typeof onProgress === "function" &&
|
|
1008
|
+
completedAttempts % APPLICATION_READY_PROGRESS_ATTEMPTS === 0
|
|
1009
|
+
) {
|
|
1010
|
+
await onProgress({
|
|
1011
|
+
elapsedSeconds: completedAttempts * APPLICATION_READY_POLL_MS / 1_000
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
752
1014
|
}
|
|
753
1015
|
}
|
|
754
1016
|
return false;
|
|
@@ -981,7 +1243,7 @@ function dockerEngineHelp(platform) {
|
|
|
981
1243
|
return `Start Docker Engine, verify the current user can access it, then run "${UNIVERSAL_COMMAND} doctor" again.`;
|
|
982
1244
|
}
|
|
983
1245
|
|
|
984
|
-
function
|
|
1246
|
+
function formatGibibytes(bytes) {
|
|
985
1247
|
return Math.floor((bytes / 1024 ** 3) * 10) / 10;
|
|
986
1248
|
}
|
|
987
1249
|
|
|
@@ -989,11 +1251,6 @@ function wait(milliseconds) {
|
|
|
989
1251
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
990
1252
|
}
|
|
991
1253
|
|
|
992
|
-
async function packageVersion() {
|
|
993
|
-
const packageJson = new URL("../package.json", import.meta.url);
|
|
994
|
-
return JSON.parse(await readFile(packageJson, "utf8")).version;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
1254
|
function helpText() {
|
|
998
1255
|
return `SpaceApp self-hosted launcher
|
|
999
1256
|
|
|
@@ -1001,7 +1258,7 @@ Usage: ${UNIVERSAL_COMMAND} <command>
|
|
|
1001
1258
|
spaceapp <command> Optional global launcher
|
|
1002
1259
|
|
|
1003
1260
|
init Create a local SpaceApp installation
|
|
1004
|
-
install [--profile auto|light|standard] [--no-open]
|
|
1261
|
+
install [--profile auto|light|standard] [--access isolated|host-root] [--no-open]
|
|
1005
1262
|
Install prerequisites, initialize, and start
|
|
1006
1263
|
up | down | status | logs Manage the Docker application
|
|
1007
1264
|
open Open the local web application
|
package/src/index.mjs
CHANGED
|
@@ -17,11 +17,12 @@ 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
|
-
const MIN_INSTALL_MEMORY_BYTES =
|
|
24
|
-
const MIN_INSTALL_MEMORY_LABEL = "8 GB";
|
|
24
|
+
const MIN_INSTALL_MEMORY_BYTES = 7 * 1024 ** 3;
|
|
25
|
+
const MIN_INSTALL_MEMORY_LABEL = "7 GiB usable (8 GB-class system)";
|
|
25
26
|
const MIN_INSTALL_FREE_DISK_BYTES = 15 * 1024 ** 3;
|
|
26
27
|
const PROFILE_RUNTIME_SETTINGS = Object.freeze({
|
|
27
28
|
light: Object.freeze({
|
|
@@ -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 });
|
|
@@ -128,21 +140,26 @@ export function installResourceChecks(resources) {
|
|
|
128
140
|
{
|
|
129
141
|
name: "Memory",
|
|
130
142
|
ok: totalMemoryBytes >= MIN_INSTALL_MEMORY_BYTES,
|
|
131
|
-
detail: `${formatGibibytes(totalMemoryBytes)}
|
|
143
|
+
detail: `${formatGibibytes(totalMemoryBytes)} GiB available; ${MIN_INSTALL_MEMORY_LABEL} required`
|
|
132
144
|
},
|
|
133
145
|
{
|
|
134
146
|
name: "Free disk",
|
|
135
147
|
ok: freeDiskBytes >= MIN_INSTALL_FREE_DISK_BYTES,
|
|
136
|
-
detail: `${formatGibibytes(freeDiskBytes)}
|
|
148
|
+
detail: `${formatGibibytes(freeDiskBytes)} GiB available; ${formatGibibytes(MIN_INSTALL_FREE_DISK_BYTES)} GiB required`
|
|
137
149
|
}
|
|
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 = {
|
|
@@ -462,7 +521,7 @@ export async function selectLatestBackupId(root) {
|
|
|
462
521
|
.at(-1);
|
|
463
522
|
if (!backupId) {
|
|
464
523
|
throw new Error(
|
|
465
|
-
|
|
524
|
+
`No portable backup exists. Run "${UNIVERSAL_COMMAND} backup" before restore.`
|
|
466
525
|
);
|
|
467
526
|
}
|
|
468
527
|
return backupId;
|
|
@@ -474,11 +533,13 @@ export async function writeRuntimeFiles(root, config) {
|
|
|
474
533
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
475
534
|
await atomicWrite(join(root, "runtime.env"), renderRuntimeEnv(config));
|
|
476
535
|
await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config));
|
|
536
|
+
await atomicWrite(join(root, "compose.host-access.yml"), renderHostAccessCompose(config));
|
|
477
537
|
}
|
|
478
538
|
|
|
479
539
|
export async function prepareInstallation(root, {
|
|
480
540
|
version,
|
|
481
|
-
profile
|
|
541
|
+
profile,
|
|
542
|
+
accessMode
|
|
482
543
|
}) {
|
|
483
544
|
validateHome(root);
|
|
484
545
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
@@ -496,7 +557,11 @@ export async function prepareInstallation(root, {
|
|
|
496
557
|
if (error?.code !== "ENOENT") {
|
|
497
558
|
throw error;
|
|
498
559
|
}
|
|
499
|
-
config = createDefaultConfig({
|
|
560
|
+
config = createDefaultConfig({
|
|
561
|
+
version,
|
|
562
|
+
profile: resolvedProfile ?? "light",
|
|
563
|
+
accessMode: resolveInstallAccessMode(accessMode)
|
|
564
|
+
});
|
|
500
565
|
}
|
|
501
566
|
if (config.version !== version) {
|
|
502
567
|
config = {
|
|
@@ -508,6 +573,10 @@ export async function prepareInstallation(root, {
|
|
|
508
573
|
if (resolvedProfile !== undefined) {
|
|
509
574
|
config = { ...config, profile: resolvedProfile };
|
|
510
575
|
}
|
|
576
|
+
const resolvedAccessMode = resolveInstallAccessMode(accessMode, config.accessMode);
|
|
577
|
+
if (config.accessMode !== resolvedAccessMode) {
|
|
578
|
+
config = { ...config, accessMode: resolvedAccessMode };
|
|
579
|
+
}
|
|
511
580
|
validateConfig(config);
|
|
512
581
|
|
|
513
582
|
const postgresPasswordPath = join(root, "secrets", "postgres-password");
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
|
|
9
|
+
function requireVersion(value, field) {
|
|
10
|
+
if (typeof value !== "string" || !VERSION_PATTERN.test(value)) {
|
|
11
|
+
throw new Error(`run-spaceapp package.json must declare a valid ${field}.`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const PACKAGE_VERSION = requireVersion(manifest.version, "version");
|
|
17
|
+
export const RUNTIME_VERSION = requireVersion(
|
|
18
|
+
manifest.spaceappRuntimeVersion,
|
|
19
|
+
"spaceappRuntimeVersion"
|
|
20
|
+
);
|
|
21
|
+
if (typeof manifest.spaceappHostRootRuntimeCompatible !== "boolean") {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"run-spaceapp package.json must declare spaceappHostRootRuntimeCompatible as a boolean."
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
export const HOST_ROOT_RUNTIME_COMPATIBLE =
|
|
27
|
+
manifest.spaceappHostRootRuntimeCompatible;
|
|
28
|
+
export const LAUNCHER_DIST_TAG = PACKAGE_VERSION.includes("-hostroot.")
|
|
29
|
+
? "personal"
|
|
30
|
+
: "latest";
|
|
31
|
+
export const UNIVERSAL_COMMAND = `npx --yes run-spaceapp@${LAUNCHER_DIST_TAG}`;
|
|
32
|
+
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"
|
|
@@ -20,6 +20,8 @@ const MAC_DOCKER_DOWNLOADS = Object.freeze({
|
|
|
20
20
|
x64: "https://desktop.docker.com/mac/main/amd64/Docker.dmg",
|
|
21
21
|
arm64: "https://desktop.docker.com/mac/main/arm64/Docker.dmg"
|
|
22
22
|
});
|
|
23
|
+
const MAC_ADMIN_PASSWORD_PROMPT =
|
|
24
|
+
"SpaceApp Mac administrator password (input hidden): ";
|
|
23
25
|
// Sources:
|
|
24
26
|
// https://learn.microsoft.com/windows/package-manager/winget/install
|
|
25
27
|
// https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/Docker/DockerDesktop
|
|
@@ -444,8 +446,8 @@ async function ensureMacDocker({
|
|
|
444
446
|
mounted = true;
|
|
445
447
|
const signatureCode = await execute({
|
|
446
448
|
command: "codesign",
|
|
447
|
-
args: ["--verify", "--deep", "--strict",
|
|
448
|
-
}, { stdin, stdout: null, stderr });
|
|
449
|
+
args: ["--verify", "--deep", "--strict", mountedApplication]
|
|
450
|
+
}, { stdin, stdout: null, stderr: null });
|
|
449
451
|
if (signatureCode !== 0) {
|
|
450
452
|
stderr.write("The Docker Desktop application signature is not valid. Installation was stopped.\n");
|
|
451
453
|
return { code: 1, reexecuted: false };
|
|
@@ -453,17 +455,33 @@ async function ensureMacDocker({
|
|
|
453
455
|
const gatekeeperCode = await execute({
|
|
454
456
|
command: "spctl",
|
|
455
457
|
args: ["--assess", "--type", "execute", "--verbose=4", mountedApplication]
|
|
456
|
-
}, { stdin, stdout: null, stderr });
|
|
458
|
+
}, { stdin, stdout: null, stderr: null });
|
|
457
459
|
if (gatekeeperCode !== 0) {
|
|
458
460
|
stderr.write("macOS Gatekeeper did not accept Docker Desktop. Installation was stopped.\n");
|
|
459
461
|
return { code: 1, reexecuted: false };
|
|
460
462
|
}
|
|
463
|
+
stdout.write("Docker Desktop signature and macOS trust verification passed.\n");
|
|
464
|
+
stdout.write(
|
|
465
|
+
"macOS administrator authorization is required to install Docker Desktop.\n" +
|
|
466
|
+
"Enter the password for your Mac administrator account—not a Docker or SpaceApp password.\n" +
|
|
467
|
+
"Characters are not displayed while you type. SpaceApp does not read, store, or log this password.\n"
|
|
468
|
+
);
|
|
461
469
|
const installCode = await execute({
|
|
462
470
|
command: "sudo",
|
|
463
|
-
args: [
|
|
471
|
+
args: [
|
|
472
|
+
"-p",
|
|
473
|
+
MAC_ADMIN_PASSWORD_PROMPT,
|
|
474
|
+
installer,
|
|
475
|
+
"--accept-license",
|
|
476
|
+
`--user=${username}`
|
|
477
|
+
]
|
|
464
478
|
}, { stdin, stdout, stderr });
|
|
465
479
|
if (installCode !== 0) {
|
|
466
|
-
stderr.write(
|
|
480
|
+
stderr.write(
|
|
481
|
+
"macOS authorization failed. Docker Desktop must be installed by a Mac administrator account.\n" +
|
|
482
|
+
"Enter that account's password—not a Docker or SpaceApp password—and rerun the same SpaceApp install command.\n" +
|
|
483
|
+
"No SpaceApp images were downloaded.\n"
|
|
484
|
+
);
|
|
467
485
|
return { code: installCode, reexecuted: false };
|
|
468
486
|
}
|
|
469
487
|
} finally {
|
|
@@ -553,24 +571,19 @@ async function ensureLinuxDocker({
|
|
|
553
571
|
stderr.write("A valid non-root Linux username is required for Docker Engine access.\n");
|
|
554
572
|
return { code: 1, reexecuted: false };
|
|
555
573
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
stdout,
|
|
559
|
-
question: [
|
|
574
|
+
stdout.write(
|
|
575
|
+
[
|
|
560
576
|
"Docker's official post-install flow uses the docker group.",
|
|
561
577
|
"Membership grants root-level privileges on this host.",
|
|
562
|
-
`
|
|
563
|
-
].join("\n")
|
|
564
|
-
|
|
565
|
-
if (!accepted) {
|
|
566
|
-
stderr.write("Docker group membership was not changed.\n");
|
|
567
|
-
return { code: 1, reexecuted: false };
|
|
568
|
-
}
|
|
578
|
+
`Adding ${username} to the docker group automatically and continuing.`
|
|
579
|
+
].join("\n") + "\n"
|
|
580
|
+
);
|
|
569
581
|
const groupCode = await execute(
|
|
570
582
|
{ command: "sudo", args: ["usermod", "-aG", "docker", username] },
|
|
571
583
|
{ stdin, stdout, stderr }
|
|
572
584
|
);
|
|
573
585
|
if (groupCode !== 0) {
|
|
586
|
+
stderr.write(`SpaceApp could not add ${username} to the docker group.\n`);
|
|
574
587
|
return { code: groupCode, reexecuted: false };
|
|
575
588
|
}
|
|
576
589
|
|
|
@@ -644,6 +657,10 @@ async function installLinuxDockerEngine({
|
|
|
644
657
|
download
|
|
645
658
|
}) {
|
|
646
659
|
const id = String(release?.ID || "").toLowerCase();
|
|
660
|
+
const idFamily = new Set([
|
|
661
|
+
id,
|
|
662
|
+
...String(release?.ID_LIKE || "").toLowerCase().split(/\s+/).filter(Boolean)
|
|
663
|
+
]);
|
|
647
664
|
if (id === "ubuntu" || id === "debian") {
|
|
648
665
|
return installAptDockerEngine({
|
|
649
666
|
arch,
|
|
@@ -659,6 +676,13 @@ async function installLinuxDockerEngine({
|
|
|
659
676
|
if (id === "fedora" || id === "rhel" || id === "centos") {
|
|
660
677
|
return installDnfDockerEngine({ id, stdin, stdout, stderr, execute });
|
|
661
678
|
}
|
|
679
|
+
if (
|
|
680
|
+
idFamily.has("arch") ||
|
|
681
|
+
idFamily.has("archlinux") ||
|
|
682
|
+
idFamily.has("cachyos")
|
|
683
|
+
) {
|
|
684
|
+
return installPacmanDockerEngine({ stdin, stdout, stderr, execute });
|
|
685
|
+
}
|
|
662
686
|
stderr.write(
|
|
663
687
|
`Automatic Docker Engine installation is not yet supported for Linux distribution "${id || "unknown"}". ` +
|
|
664
688
|
"Install Docker Engine and the Compose plugin from https://docs.docker.com/engine/install/ and rerun the same command.\n"
|
|
@@ -666,6 +690,23 @@ async function installLinuxDockerEngine({
|
|
|
666
690
|
return 1;
|
|
667
691
|
}
|
|
668
692
|
|
|
693
|
+
function installPacmanDockerEngine({ stdin, stdout, stderr, execute }) {
|
|
694
|
+
return execute(
|
|
695
|
+
{
|
|
696
|
+
command: "sudo",
|
|
697
|
+
args: [
|
|
698
|
+
"pacman",
|
|
699
|
+
"-S",
|
|
700
|
+
"--needed",
|
|
701
|
+
"--noconfirm",
|
|
702
|
+
"docker",
|
|
703
|
+
"docker-compose"
|
|
704
|
+
]
|
|
705
|
+
},
|
|
706
|
+
{ stdin, stdout, stderr }
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
669
710
|
async function installAptDockerEngine({
|
|
670
711
|
arch,
|
|
671
712
|
id,
|
|
@@ -786,7 +827,11 @@ async function readLinuxOsRelease() {
|
|
|
786
827
|
return values;
|
|
787
828
|
}
|
|
788
829
|
|
|
789
|
-
function linuxReentryCommand({
|
|
830
|
+
function linuxReentryCommand({
|
|
831
|
+
requestedProfile = "auto",
|
|
832
|
+
requestedAccessMode,
|
|
833
|
+
noOpen = false
|
|
834
|
+
} = {}) {
|
|
790
835
|
if (!["auto", "light", "standard"].includes(requestedProfile)) {
|
|
791
836
|
throw new Error("Invalid SpaceApp profile for Docker group re-entry.");
|
|
792
837
|
}
|
|
@@ -799,6 +844,7 @@ function linuxReentryCommand({ requestedProfile = "auto", noOpen = false } = {})
|
|
|
799
844
|
"install",
|
|
800
845
|
"--profile",
|
|
801
846
|
requestedProfile,
|
|
847
|
+
...installAccessArgs(requestedAccessMode, "Docker group re-entry"),
|
|
802
848
|
...(noOpen ? ["--no-open"] : [])
|
|
803
849
|
].map(shellQuote).join(" ");
|
|
804
850
|
}
|
|
@@ -807,6 +853,7 @@ export function windowsResumeScript({
|
|
|
807
853
|
executable = process.execPath,
|
|
808
854
|
entrypoint = fileURLToPath(new URL("../bin/spaceapp.mjs", import.meta.url)),
|
|
809
855
|
requestedProfile = "auto",
|
|
856
|
+
requestedAccessMode,
|
|
810
857
|
noOpen = false
|
|
811
858
|
} = {}) {
|
|
812
859
|
if (!["auto", "light", "standard"].includes(requestedProfile)) {
|
|
@@ -830,6 +877,7 @@ export function windowsResumeScript({
|
|
|
830
877
|
"install",
|
|
831
878
|
"--profile",
|
|
832
879
|
requestedProfile,
|
|
880
|
+
...installAccessArgs(requestedAccessMode, "Windows resume"),
|
|
833
881
|
...(noOpen ? ["--no-open"] : [])
|
|
834
882
|
].join(" ");
|
|
835
883
|
return [
|
|
@@ -847,6 +895,7 @@ export function windowsResumeScript({
|
|
|
847
895
|
async function scheduleWindowsInstallResume({
|
|
848
896
|
root,
|
|
849
897
|
requestedProfile = "auto",
|
|
898
|
+
requestedAccessMode,
|
|
850
899
|
noOpen = false,
|
|
851
900
|
execute,
|
|
852
901
|
stdin,
|
|
@@ -865,7 +914,7 @@ async function scheduleWindowsInstallResume({
|
|
|
865
914
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
866
915
|
await writeFile(
|
|
867
916
|
resumePath,
|
|
868
|
-
windowsResumeScript({ requestedProfile, noOpen }),
|
|
917
|
+
windowsResumeScript({ requestedProfile, requestedAccessMode, noOpen }),
|
|
869
918
|
{ encoding: "utf8", mode: 0o600 }
|
|
870
919
|
);
|
|
871
920
|
const code = await execute(
|
|
@@ -882,6 +931,16 @@ async function scheduleWindowsInstallResume({
|
|
|
882
931
|
return code;
|
|
883
932
|
}
|
|
884
933
|
|
|
934
|
+
function installAccessArgs(requestedAccessMode, context) {
|
|
935
|
+
if (requestedAccessMode === undefined) {
|
|
936
|
+
return [];
|
|
937
|
+
}
|
|
938
|
+
if (requestedAccessMode === "isolated" || requestedAccessMode === "host-root") {
|
|
939
|
+
return ["--access", requestedAccessMode];
|
|
940
|
+
}
|
|
941
|
+
throw new Error(`Invalid SpaceApp access mode for ${context}.`);
|
|
942
|
+
}
|
|
943
|
+
|
|
885
944
|
function shellQuote(value) {
|
|
886
945
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
887
946
|
}
|