run-spaceapp 0.1.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/src/cli.mjs ADDED
@@ -0,0 +1,540 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import process from "node:process";
5
+ import {
6
+ addWorkspace,
7
+ composeCommand,
8
+ credentialProviders,
9
+ initializeInstallation,
10
+ inspectSystemResources,
11
+ installResourceChecks,
12
+ loadConfig,
13
+ removeCredential,
14
+ removeWorkspace,
15
+ resolveInstallProfile,
16
+ resolveSpaceAppHome,
17
+ saveConfig,
18
+ selectLatestBackupId,
19
+ writeCredential,
20
+ writeRuntimeFiles,
21
+ writeSetupToken
22
+ } from "./index.mjs";
23
+
24
+ export async function run(argv, {
25
+ env = process.env,
26
+ platform = process.platform,
27
+ stdout = process.stdout,
28
+ stderr = process.stderr,
29
+ stdin = process.stdin,
30
+ execute = executeCommand,
31
+ inspectResources = inspectSystemResources
32
+ } = {}) {
33
+ const [command = "help", ...args] = argv;
34
+ const root = resolveSpaceAppHome({ env, platform });
35
+ const version = await packageVersion();
36
+
37
+ if (command === "help" || command === "--help" || command === "-h") {
38
+ stdout.write(helpText());
39
+ return 0;
40
+ }
41
+ if (command === "--version" || command === "-v") {
42
+ stdout.write(`${version}\n`);
43
+ return 0;
44
+ }
45
+ if (command === "install") {
46
+ return installCommand(args, {
47
+ root,
48
+ version,
49
+ platform,
50
+ stdin,
51
+ stdout,
52
+ stderr,
53
+ execute,
54
+ inspectResources
55
+ });
56
+ }
57
+ if (command === "init") {
58
+ assertNoArgs(args, "init");
59
+ const result = await initializeInstallation(root, { version });
60
+ stdout.write(`SpaceApp initialized at ${root}\n`);
61
+ if (result.setupToken) {
62
+ stdout.write(`One-time setup token: ${result.setupToken}\n`);
63
+ stdout.write("Store it temporarily; it expires after first owner setup.\n");
64
+ }
65
+ stdout.write("Next: spaceapp doctor && spaceapp up && spaceapp open\n");
66
+ return 0;
67
+ }
68
+
69
+ const config = await loadConfig(root);
70
+ await writeRuntimeFiles(root, config);
71
+
72
+ if (["up", "down", "status", "logs"].includes(command)) {
73
+ assertNoArgs(args, command);
74
+ return execute(composeCommand(command, root, { profile: config.profile }), { stdin, stdout, stderr });
75
+ }
76
+ if (command === "open") {
77
+ assertNoArgs(args, "open");
78
+ return openBrowser(`http://${config.bindHost}:${config.port}`, platform, execute, { stdin, stdout, stderr });
79
+ }
80
+ if (command === "doctor") {
81
+ assertNoArgs(args, "doctor");
82
+ return doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources });
83
+ }
84
+ if (command === "workspace") {
85
+ return workspaceCommand(args, { root, config, stdout });
86
+ }
87
+ if (command === "credentials") {
88
+ return credentialsCommand(args, { root, config, stdin, stdout, stderr, execute });
89
+ }
90
+ if (command === "provider") {
91
+ return providerCommand(args, { root, config, stdin, stdout, stderr, execute });
92
+ }
93
+ if (command === "owner") {
94
+ return ownerCommand(args, { root, config, stdin, stdout, stderr, execute });
95
+ }
96
+ if (command === "update") {
97
+ return updateCommand(args, { root, config, version, stdin, stdout, stderr, execute });
98
+ }
99
+ if (command === "rollback") {
100
+ assertNoArgs(args, "rollback");
101
+ if (!config.previousVersion) {
102
+ throw new Error("No previous SpaceApp version is recorded.");
103
+ }
104
+ const rollback = {
105
+ ...config,
106
+ version: config.previousVersion,
107
+ previousVersion: config.version
108
+ };
109
+ await writeRuntimeFiles(root, rollback);
110
+ const pullCode = await execute(composeCommand("pull", root, { profile: rollback.profile }), { stdin, stdout, stderr });
111
+ if (pullCode !== 0) {
112
+ await writeRuntimeFiles(root, config);
113
+ return pullCode;
114
+ }
115
+ const upCode = await execute(composeCommand("up", root, { profile: rollback.profile }), { stdin, stdout, stderr });
116
+ if (upCode !== 0) {
117
+ await writeRuntimeFiles(root, config);
118
+ return upCode;
119
+ }
120
+ await saveConfig(root, rollback);
121
+ stdout.write(`Rolled back to SpaceApp ${rollback.version}.\n`);
122
+ return 0;
123
+ }
124
+ if (command === "backup") {
125
+ assertNoArgs(args, command);
126
+ return execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
127
+ }
128
+ if (command === "restore") {
129
+ assertNoArgs(args, command);
130
+ const confirmation = await readSecret(
131
+ stdin,
132
+ stdout,
133
+ "Type RESTORE to replace current SpaceApp data with the latest backup: ",
134
+ { mask: false }
135
+ );
136
+ if (confirmation !== "RESTORE") {
137
+ throw new Error("Restore cancelled.");
138
+ }
139
+ const backupId = await selectLatestBackupId(root);
140
+ const backupCode = await execute(composeCommand("backup", root, { profile: config.profile }), { stdin, stdout, stderr });
141
+ if (backupCode !== 0) return backupCode;
142
+ const stopCode = await execute(composeCommand("stopForRestore", root, { profile: config.profile }), { stdin, stdout, stderr });
143
+ if (stopCode !== 0) return stopCode;
144
+ const restoreCode = await execute(
145
+ composeCommand("restore", root, { backupId, profile: config.profile }),
146
+ { stdin, stdout, stderr }
147
+ );
148
+ if (restoreCode !== 0) return restoreCode;
149
+ return execute(composeCommand("up", root, { profile: config.profile }), { stdin, stdout, stderr });
150
+ }
151
+ if (command === "uninstall") {
152
+ if (args.length === 0) {
153
+ const code = await execute(composeCommand("down", root, { profile: config.profile }), { stdin, stdout, stderr });
154
+ if (code === 0) {
155
+ stdout.write(`Containers removed. Data and configuration remain at ${root}.\n`);
156
+ }
157
+ return code;
158
+ }
159
+ if (args.length === 1 && args[0] === "--purge-data") {
160
+ const confirmation = await readSecret(stdin, stdout, "Type DELETE to remove Docker volumes: ", { mask: false });
161
+ if (confirmation !== "DELETE") {
162
+ throw new Error("Purge cancelled.");
163
+ }
164
+ return execute(composeCommand("purge", root, { profile: config.profile }), { stdin, stdout, stderr });
165
+ }
166
+ throw new Error("Usage: spaceapp uninstall [--purge-data]");
167
+ }
168
+
169
+ throw new Error(`Unknown command "${command}". Run "spaceapp help".`);
170
+ }
171
+
172
+ async function installCommand(args, {
173
+ root,
174
+ version,
175
+ platform,
176
+ stdin,
177
+ stdout,
178
+ stderr,
179
+ execute,
180
+ inspectResources
181
+ }) {
182
+ const { requestedProfile, noOpen } = parseInstallArgs(args);
183
+ const resources = await inspectResources(root);
184
+ const profile = resolveInstallProfile(requestedProfile, resources.totalMemoryBytes);
185
+ const result = await initializeInstallation(root, { version, profile });
186
+
187
+ stdout.write(
188
+ `Selected profile: ${profile} (${formatGigabytes(resources.totalMemoryBytes)} GB system memory detected).\n`
189
+ );
190
+ stdout.write(`SpaceApp installation root: ${root}\n`);
191
+ if (result.setupToken) {
192
+ stdout.write(`One-time setup token: ${result.setupToken}\n`);
193
+ stdout.write("Store it temporarily; it expires after first owner setup.\n");
194
+ }
195
+
196
+ const doctorCode = await doctor({
197
+ root,
198
+ platform,
199
+ stdout,
200
+ stderr,
201
+ execute,
202
+ stdin,
203
+ inspectResources,
204
+ resources
205
+ });
206
+ if (doctorCode !== 0) {
207
+ stderr.write("Installation stopped before downloading images. Fix the failed checks and run the same command again.\n");
208
+ return doctorCode;
209
+ }
210
+ const pullCode = await execute(composeCommand("pull", root, { profile }), { stdin, stdout, stderr });
211
+ if (pullCode !== 0) return pullCode;
212
+ const upCode = await execute(composeCommand("up", root, { profile }), { stdin, stdout, stderr });
213
+ if (upCode !== 0) return upCode;
214
+
215
+ const url = `http://${result.config.bindHost}:${result.config.port}`;
216
+ stdout.write(`SpaceApp is running at ${url}\n`);
217
+ stdout.write('Next: add CLI credentials with "spaceapp credentials set <provider>".\n');
218
+ if (noOpen) return 0;
219
+ return openBrowser(url, platform, execute, { stdin, stdout, stderr });
220
+ }
221
+
222
+ function parseInstallArgs(args) {
223
+ let requestedProfile = "auto";
224
+ let noOpen = false;
225
+ for (let index = 0; index < args.length; index += 1) {
226
+ const argument = args[index];
227
+ if (argument === "--no-open" && !noOpen) {
228
+ noOpen = true;
229
+ continue;
230
+ }
231
+ if (argument === "--profile" && index + 1 < args.length) {
232
+ requestedProfile = args[index + 1];
233
+ index += 1;
234
+ continue;
235
+ }
236
+ if (argument.startsWith("--profile=")) {
237
+ requestedProfile = argument.slice("--profile=".length);
238
+ continue;
239
+ }
240
+ throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
241
+ }
242
+ if (!["auto", "light", "standard"].includes(requestedProfile)) {
243
+ throw new Error("Usage: spaceapp install [--profile auto|light|standard] [--no-open]");
244
+ }
245
+ return { requestedProfile, noOpen };
246
+ }
247
+
248
+ async function workspaceCommand(args, { root, config, stdout }) {
249
+ const [action, identity, ...rest] = args;
250
+ if (action === "add") {
251
+ if (!identity || rest.some((arg) => arg !== "--read-only")) {
252
+ throw new Error("Usage: spaceapp workspace add <absolute-path> [--read-only]");
253
+ }
254
+ const updated = await addWorkspace(config, identity, { readOnly: rest.includes("--read-only") });
255
+ await saveConfig(root, updated);
256
+ await writeRuntimeFiles(root, updated);
257
+ stdout.write(`Workspace registered: ${identity}\n`);
258
+ return 0;
259
+ }
260
+ if (action === "remove") {
261
+ if (!identity || rest.length > 0) {
262
+ throw new Error("Usage: spaceapp workspace remove <id-or-absolute-path>");
263
+ }
264
+ const updated = removeWorkspace(config, identity);
265
+ await saveConfig(root, updated);
266
+ await writeRuntimeFiles(root, updated);
267
+ stdout.write(`Workspace removed: ${identity}\n`);
268
+ return 0;
269
+ }
270
+ if (action === "list" && args.length === 1) {
271
+ stdout.write(`${JSON.stringify(config.workspaces, null, 2)}\n`);
272
+ return 0;
273
+ }
274
+ throw new Error("Usage: spaceapp workspace <add|remove|list>");
275
+ }
276
+
277
+ async function credentialsCommand(args, { root, config, stdin, stdout, stderr, execute }) {
278
+ const [action, provider, ...rest] = args;
279
+ if (action === "list" && args.length === 1) {
280
+ stdout.write(`${JSON.stringify(credentialProviders(), null, 2)}\n`);
281
+ return 0;
282
+ }
283
+ if (action === "set") {
284
+ if (!provider || rest.length > 0) {
285
+ throw new Error("Usage: spaceapp credentials set <provider> (the value is read from stdin)");
286
+ }
287
+ const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `);
288
+ await writeCredential(root, provider, value);
289
+ const syncCode = await execute(
290
+ composeCommand("syncCredentials", root, { profile: config.profile }),
291
+ { stdin, stdout, stderr }
292
+ );
293
+ if (syncCode !== 0) {
294
+ stderr.write(`Credential stored for ${provider}, but the CLI service could not be refreshed.\n`);
295
+ return syncCode;
296
+ }
297
+ stdout.write(`Credential stored and applied for ${provider}.\n`);
298
+ return syncCode;
299
+ }
300
+ if (action === "remove") {
301
+ if (!provider || rest.length > 0) {
302
+ throw new Error("Usage: spaceapp credentials remove <provider>");
303
+ }
304
+ const removed = await removeCredential(root, provider);
305
+ const syncCode = await execute(
306
+ composeCommand("syncCredentials", root, { profile: config.profile }),
307
+ { stdin, stdout, stderr }
308
+ );
309
+ if (syncCode !== 0) {
310
+ stderr.write(`Credential file state changed for ${provider}, but the CLI service could not be refreshed.\n`);
311
+ return syncCode;
312
+ }
313
+ stdout.write(removed ? `Credential removed and applied for ${provider}.\n` : `No credential stored for ${provider}.\n`);
314
+ return syncCode;
315
+ }
316
+ throw new Error("Usage: spaceapp credentials <set|remove|list>");
317
+ }
318
+
319
+ async function providerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
320
+ if (args.length !== 2 || args[0] !== "install" || args[1] !== "claude") {
321
+ throw new Error("Usage: spaceapp provider install claude");
322
+ }
323
+ stdout.write("Installing Claude Code from Anthropic into this installation's private provider volume.\n");
324
+ return execute(composeCommand("installClaude", root, { profile: config.profile }), { stdin, stdout, stderr });
325
+ }
326
+
327
+ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) {
328
+ if (args.length === 1 && args[0] === "rotate-setup-token") {
329
+ const token = randomBytes(32).toString("base64url");
330
+ const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile }), {
331
+ stdin,
332
+ stdout,
333
+ stderr,
334
+ input: `${token}\n`
335
+ });
336
+ if (code !== 0) return code;
337
+ await writeSetupToken(root, token);
338
+ stdout.write(`New one-time setup token: ${token}\n`);
339
+ stdout.write("It expires in 15 minutes and only works before the first owner is claimed.\n");
340
+ return 0;
341
+ }
342
+ if (args.length !== 1 || args[0] !== "reset-password") {
343
+ throw new Error("Usage: spaceapp owner <reset-password|rotate-setup-token>");
344
+ }
345
+ const password = await readSecret(stdin, stdout, "New owner password: ");
346
+ if (password.length < 12) {
347
+ throw new Error("Owner password must be at least 12 characters.");
348
+ }
349
+ return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile }), {
350
+ stdin,
351
+ stdout,
352
+ stderr,
353
+ input: `${password}\n`
354
+ });
355
+ }
356
+
357
+ async function updateCommand(args, { root, config, version, stdin, stdout, stderr, execute }) {
358
+ if (args.length > 1) {
359
+ throw new Error("Usage: spaceapp update [version]");
360
+ }
361
+ const targetVersion = args[0] || version;
362
+ const updated = {
363
+ ...config,
364
+ version: targetVersion,
365
+ previousVersion: config.version
366
+ };
367
+ await writeRuntimeFiles(root, updated);
368
+ const pullCode = await execute(composeCommand("pull", root, { profile: updated.profile }), { stdin, stdout, stderr });
369
+ if (pullCode !== 0) {
370
+ await writeRuntimeFiles(root, config);
371
+ return pullCode;
372
+ }
373
+ const upCode = await execute(composeCommand("up", root, { profile: updated.profile }), { stdin, stdout, stderr });
374
+ if (upCode !== 0) {
375
+ await writeRuntimeFiles(root, config);
376
+ return upCode;
377
+ }
378
+ await saveConfig(root, updated);
379
+ stdout.write(`Updated to SpaceApp ${targetVersion}.\n`);
380
+ return 0;
381
+ }
382
+
383
+ async function doctor({
384
+ root,
385
+ platform,
386
+ stdout,
387
+ stderr,
388
+ execute,
389
+ stdin,
390
+ inspectResources,
391
+ resources
392
+ }) {
393
+ const detectedResources = resources ?? await inspectResources(root);
394
+ const checks = [
395
+ { name: "Node.js", ok: Number(process.versions.node.split(".")[0]) >= 20, detail: process.version },
396
+ { name: "Configuration", ok: true, detail: root },
397
+ ...installResourceChecks(detectedResources)
398
+ ];
399
+ let dockerMissing = false;
400
+ for (const probe of [
401
+ { name: "Docker", command: "docker", args: ["--version"] },
402
+ { name: "Docker Compose", command: "docker", args: ["compose", "version"] }
403
+ ]) {
404
+ const code = await execute(probe, { stdin, stdout: null, stderr: null });
405
+ if (code !== 0) dockerMissing = true;
406
+ checks.push({ name: probe.name, ok: code === 0, detail: code === 0 ? "available" : "missing" });
407
+ }
408
+ for (const check of checks) {
409
+ (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`);
410
+ }
411
+ if (dockerMissing) {
412
+ stderr.write(`${dockerInstallHelp(platform)}\n`);
413
+ }
414
+ return checks.every((check) => check.ok) ? 0 : 1;
415
+ }
416
+
417
+ export async function readSecret(stdin, stdout, prompt, { mask = true } = {}) {
418
+ stdout.write(prompt);
419
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
420
+ let value = "";
421
+ for await (const chunk of stdin) {
422
+ value += chunk;
423
+ }
424
+ stdout.write("\n");
425
+ return value.replace(/[\r\n]+$/, "");
426
+ }
427
+ stdin.setRawMode(true);
428
+ stdin.resume();
429
+ stdin.setEncoding("utf8");
430
+ let value = "";
431
+ try {
432
+ for await (const chunk of stdin) {
433
+ for (const character of chunk) {
434
+ if (character === "\u0003") {
435
+ throw new Error("Input cancelled.");
436
+ }
437
+ if (character === "\r" || character === "\n") {
438
+ stdout.write("\n");
439
+ return value;
440
+ }
441
+ if (character === "\u007f") {
442
+ if (value.length > 0) {
443
+ value = value.slice(0, -1);
444
+ if (mask) stdout.write("\b \b");
445
+ }
446
+ continue;
447
+ }
448
+ value += character;
449
+ if (mask) stdout.write("*");
450
+ }
451
+ }
452
+ return value;
453
+ } finally {
454
+ stdin.setRawMode(false);
455
+ stdin.pause();
456
+ }
457
+ }
458
+
459
+ export function executeCommand(spec, { stdin, stdout, stderr, input } = {}) {
460
+ return new Promise((resolve, reject) => {
461
+ const child = spawn(spec.command, spec.args, {
462
+ shell: false,
463
+ stdio: [input === undefined ? (stdin || "inherit") : "pipe", stdout || "ignore", stderr || "ignore"]
464
+ });
465
+ child.once("error", (error) => {
466
+ if (error?.code === "ENOENT") {
467
+ resolve(127);
468
+ } else {
469
+ reject(error);
470
+ }
471
+ });
472
+ child.once("exit", (code, signal) => {
473
+ resolve(code ?? (signal ? 1 : 0));
474
+ });
475
+ if (input !== undefined) {
476
+ child.stdin.end(input);
477
+ }
478
+ });
479
+ }
480
+
481
+ function openBrowser(url, platform, execute, io) {
482
+ if (platform === "darwin") {
483
+ return execute({ command: "open", args: [url] }, io);
484
+ }
485
+ if (platform === "win32") {
486
+ return execute({ command: "cmd", args: ["/d", "/s", "/c", "start", "", url] }, io);
487
+ }
488
+ return execute({ command: "xdg-open", args: [url] }, io);
489
+ }
490
+
491
+ function assertNoArgs(args, command) {
492
+ if (args.length > 0) {
493
+ throw new Error(`Usage: spaceapp ${command}`);
494
+ }
495
+ }
496
+
497
+ function dockerInstallHelp(platform) {
498
+ if (platform === "win32") {
499
+ return "Install Docker Desktop with its WSL2 backend, start it, then run spaceapp install again: https://docs.docker.com/desktop/setup/install/windows-install/";
500
+ }
501
+ if (platform === "darwin") {
502
+ return "Install and start Docker Desktop for macOS, then run spaceapp install again: https://docs.docker.com/desktop/setup/install/mac-install/";
503
+ }
504
+ return "Install Docker Engine and the Docker Compose plugin, then run spaceapp install again: https://docs.docker.com/engine/install/";
505
+ }
506
+
507
+ function formatGigabytes(bytes) {
508
+ return Math.floor((bytes / 1024 ** 3) * 10) / 10;
509
+ }
510
+
511
+ async function packageVersion() {
512
+ const packageJson = new URL("../package.json", import.meta.url);
513
+ return JSON.parse(await readFile(packageJson, "utf8")).version;
514
+ }
515
+
516
+ function helpText() {
517
+ return `SpaceApp self-hosted launcher
518
+
519
+ Usage: spaceapp <command>
520
+
521
+ init Create a local SpaceApp installation
522
+ install [--profile auto|light|standard] [--no-open]
523
+ Initialize, check, download, start, and open
524
+ up | down | status | logs Manage the Docker application
525
+ open Open the local web application
526
+ doctor Check Node, Docker, and Compose
527
+ update [version] | rollback Update or roll back images
528
+ backup | restore Back up or restore persistent state
529
+ workspace add <path> [--read-only]
530
+ workspace remove <id-or-path>
531
+ workspace list
532
+ credentials set <provider> Read a credential from masked stdin
533
+ credentials remove <provider>
534
+ credentials list
535
+ provider install claude Owner-initiated Anthropic package install
536
+ owner reset-password Read the new password from masked stdin
537
+ owner rotate-setup-token Replace an expired unclaimed setup token
538
+ uninstall [--purge-data] Remove containers; keep data by default
539
+ `;
540
+ }