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