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/LICENSE +201 -0
- package/NOTICE +11 -0
- package/README.md +51 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/spaceapp.mjs +11 -0
- package/package.json +48 -0
- package/src/cli.mjs +540 -0
- package/src/index.mjs +613 -0
- package/src/string-utils.mjs +38 -0
- package/templates/compose.yml +183 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
copyFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
statfs,
|
|
11
|
+
stat,
|
|
12
|
+
writeFile
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { availableParallelism, homedir, totalmem } from "node:os";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
normalizeWorkspaceName,
|
|
19
|
+
stripTrailingLineEndings
|
|
20
|
+
} from "./string-utils.mjs";
|
|
21
|
+
|
|
22
|
+
const CONFIG_SCHEMA_VERSION = 2;
|
|
23
|
+
const AUTO_LIGHT_MEMORY_THRESHOLD_BYTES = 12 * 1024 ** 3;
|
|
24
|
+
const MIN_INSTALL_CPU_COUNT = 4;
|
|
25
|
+
const MIN_INSTALL_MEMORY_BYTES = 8 * 1024 ** 3;
|
|
26
|
+
const MIN_INSTALL_FREE_DISK_BYTES = 15 * 1024 ** 3;
|
|
27
|
+
const PROFILE_RUNTIME_SETTINGS = Object.freeze({
|
|
28
|
+
light: Object.freeze({
|
|
29
|
+
browserEnabled: false,
|
|
30
|
+
coreMemoryLimit: "2g",
|
|
31
|
+
coreCpuLimit: "2.0",
|
|
32
|
+
cliMemoryLimit: "1536m",
|
|
33
|
+
cliCpuLimit: "1.5",
|
|
34
|
+
browserMemoryLimit: "1536m",
|
|
35
|
+
browserCpuLimit: "1.5",
|
|
36
|
+
postgresMemoryLimit: "768m",
|
|
37
|
+
postgresCpuLimit: "1.0",
|
|
38
|
+
temporalMemoryLimit: "768m",
|
|
39
|
+
temporalCpuLimit: "1.0"
|
|
40
|
+
}),
|
|
41
|
+
standard: Object.freeze({
|
|
42
|
+
browserEnabled: true,
|
|
43
|
+
coreMemoryLimit: "4g",
|
|
44
|
+
coreCpuLimit: "4.0",
|
|
45
|
+
cliMemoryLimit: "3g",
|
|
46
|
+
cliCpuLimit: "3.0",
|
|
47
|
+
browserMemoryLimit: "2g",
|
|
48
|
+
browserCpuLimit: "2.0",
|
|
49
|
+
postgresMemoryLimit: "1g",
|
|
50
|
+
postgresCpuLimit: "2.0",
|
|
51
|
+
temporalMemoryLimit: "1g",
|
|
52
|
+
temporalCpuLimit: "2.0"
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
const SECRET_FIELD = /password|secret|token|api.?key|credential/i;
|
|
56
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
57
|
+
const BACKUP_ID_PATTERN = /^spaceapp-backup-\d{8}T\d{9}Z$/;
|
|
58
|
+
const PROVIDERS = Object.freeze({
|
|
59
|
+
bundled: Object.freeze(["codex", "gemini", "opencode", "qwen", "kimi", "grok"]),
|
|
60
|
+
ownerInstalled: Object.freeze(["claude"]),
|
|
61
|
+
experimental: Object.freeze(["deepseek"])
|
|
62
|
+
});
|
|
63
|
+
const ALL_PROVIDERS = new Set(Object.values(PROVIDERS).flat());
|
|
64
|
+
const CONFIG_KEYS = new Set([
|
|
65
|
+
"schemaVersion",
|
|
66
|
+
"version",
|
|
67
|
+
"previousVersion",
|
|
68
|
+
"bindHost",
|
|
69
|
+
"port",
|
|
70
|
+
"telemetry",
|
|
71
|
+
"profile",
|
|
72
|
+
"workspaces"
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export function resolveSpaceAppHome({
|
|
76
|
+
env = process.env,
|
|
77
|
+
platform = process.platform,
|
|
78
|
+
home = homedir()
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (env.SPACEAPP_HOME) {
|
|
81
|
+
return resolve(env.SPACEAPP_HOME);
|
|
82
|
+
}
|
|
83
|
+
if (platform === "win32") {
|
|
84
|
+
return resolve(env.APPDATA || join(home, "AppData", "Roaming"), "SpaceApp");
|
|
85
|
+
}
|
|
86
|
+
if (platform === "darwin") {
|
|
87
|
+
return resolve(home, "Library", "Application Support", "SpaceApp");
|
|
88
|
+
}
|
|
89
|
+
return resolve(env.XDG_CONFIG_HOME || join(home, ".config"), "spaceapp");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveInstallProfile(requestedProfile, totalMemoryBytes) {
|
|
93
|
+
if (requestedProfile === "light" || requestedProfile === "standard") {
|
|
94
|
+
return requestedProfile;
|
|
95
|
+
}
|
|
96
|
+
if (requestedProfile !== "auto") {
|
|
97
|
+
throw new Error("Install profile must be auto, light, or standard.");
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) {
|
|
100
|
+
throw new Error("Total system memory must be available for automatic profile selection.");
|
|
101
|
+
}
|
|
102
|
+
return totalMemoryBytes < AUTO_LIGHT_MEMORY_THRESHOLD_BYTES ? "light" : "standard";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function inspectSystemResources(root) {
|
|
106
|
+
validateHome(root);
|
|
107
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
108
|
+
const fileSystem = await statfs(root);
|
|
109
|
+
return {
|
|
110
|
+
cpuCount: availableParallelism(),
|
|
111
|
+
totalMemoryBytes: totalmem(),
|
|
112
|
+
freeDiskBytes: Number(fileSystem.bavail) * Number(fileSystem.bsize)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function installResourceChecks(resources) {
|
|
117
|
+
const cpuCount = Number(resources?.cpuCount);
|
|
118
|
+
const totalMemoryBytes = Number(resources?.totalMemoryBytes);
|
|
119
|
+
const freeDiskBytes = Number(resources?.freeDiskBytes);
|
|
120
|
+
if (![cpuCount, totalMemoryBytes, freeDiskBytes].every((value) => Number.isFinite(value) && value >= 0)) {
|
|
121
|
+
throw new Error("System CPU, memory, and free-disk information is required.");
|
|
122
|
+
}
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
name: "CPU",
|
|
126
|
+
ok: cpuCount >= MIN_INSTALL_CPU_COUNT,
|
|
127
|
+
detail: `${cpuCount} available; ${MIN_INSTALL_CPU_COUNT} required`
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "Memory",
|
|
131
|
+
ok: totalMemoryBytes >= MIN_INSTALL_MEMORY_BYTES,
|
|
132
|
+
detail: `${formatGibibytes(totalMemoryBytes)} GB available; ${formatGibibytes(MIN_INSTALL_MEMORY_BYTES)} GB required`
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "Free disk",
|
|
136
|
+
ok: freeDiskBytes >= MIN_INSTALL_FREE_DISK_BYTES,
|
|
137
|
+
detail: `${formatGibibytes(freeDiskBytes)} GB available; ${formatGibibytes(MIN_INSTALL_FREE_DISK_BYTES)} GB required`
|
|
138
|
+
}
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createDefaultConfig({ version, profile = "standard" }) {
|
|
143
|
+
assertVersion(version);
|
|
144
|
+
if (profile !== "light" && profile !== "standard") {
|
|
145
|
+
throw new Error("Default config requires a resolved light or standard profile.");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
149
|
+
version,
|
|
150
|
+
previousVersion: null,
|
|
151
|
+
bindHost: "127.0.0.1",
|
|
152
|
+
port: 4911,
|
|
153
|
+
telemetry: false,
|
|
154
|
+
profile,
|
|
155
|
+
workspaces: []
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function validateConfig(config) {
|
|
160
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
161
|
+
throw new Error("SpaceApp config must be an object.");
|
|
162
|
+
}
|
|
163
|
+
for (const key of Object.keys(config)) {
|
|
164
|
+
if (SECRET_FIELD.test(key)) {
|
|
165
|
+
throw new Error(`SpaceApp config cannot contain secret field "${key}".`);
|
|
166
|
+
}
|
|
167
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
168
|
+
throw new Error(`Unsupported SpaceApp config field "${key}".`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
172
|
+
throw new Error(`Unsupported SpaceApp config schema ${config.schemaVersion}.`);
|
|
173
|
+
}
|
|
174
|
+
assertVersion(config.version);
|
|
175
|
+
if (config.previousVersion !== null) {
|
|
176
|
+
assertVersion(config.previousVersion);
|
|
177
|
+
}
|
|
178
|
+
if (config.bindHost !== "127.0.0.1" && config.bindHost !== "0.0.0.0") {
|
|
179
|
+
throw new Error("bindHost must be 127.0.0.1 or 0.0.0.0.");
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535) {
|
|
182
|
+
throw new Error("port must be an integer between 1024 and 65535.");
|
|
183
|
+
}
|
|
184
|
+
if (typeof config.telemetry !== "boolean") {
|
|
185
|
+
throw new Error("telemetry must be boolean.");
|
|
186
|
+
}
|
|
187
|
+
if (!["light", "standard"].includes(config.profile)) {
|
|
188
|
+
throw new Error("profile must be light or standard.");
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(config.workspaces)) {
|
|
191
|
+
throw new Error("workspaces must be an array.");
|
|
192
|
+
}
|
|
193
|
+
for (const workspace of config.workspaces) {
|
|
194
|
+
validateWorkspace(workspace);
|
|
195
|
+
}
|
|
196
|
+
return config;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function saveConfig(root, config) {
|
|
200
|
+
validateHome(root);
|
|
201
|
+
validateConfig(config);
|
|
202
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
203
|
+
await atomicWrite(join(root, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function loadConfig(root) {
|
|
207
|
+
validateHome(root);
|
|
208
|
+
const config = migrateConfig(JSON.parse(await readFile(join(root, "config.json"), "utf8")));
|
|
209
|
+
return validateConfig(config);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function migrateConfig(config) {
|
|
213
|
+
if (config?.schemaVersion !== 1) {
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
const legacyProfiles = {
|
|
217
|
+
core: "light",
|
|
218
|
+
full: "standard"
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
...config,
|
|
222
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
223
|
+
profile: legacyProfiles[config.profile] ?? config.profile
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function addWorkspace(config, hostPath, { readOnly = false } = {}) {
|
|
228
|
+
validateConfig(config);
|
|
229
|
+
if (!isAbsolute(hostPath)) {
|
|
230
|
+
throw new Error("Workspace path must be absolute.");
|
|
231
|
+
}
|
|
232
|
+
const absolutePath = resolve(hostPath);
|
|
233
|
+
const pathStat = await stat(absolutePath);
|
|
234
|
+
if (!pathStat.isDirectory()) {
|
|
235
|
+
throw new Error("Workspace path must be an existing directory.");
|
|
236
|
+
}
|
|
237
|
+
if (config.workspaces.some((workspace) => workspace.hostPath === absolutePath)) {
|
|
238
|
+
return structuredClone(config);
|
|
239
|
+
}
|
|
240
|
+
const name = normalizeWorkspaceName(basename(absolutePath));
|
|
241
|
+
const suffix = createHash("sha256").update(absolutePath).digest("hex").slice(0, 8);
|
|
242
|
+
const workspace = {
|
|
243
|
+
id: `${name}-${suffix}`,
|
|
244
|
+
name,
|
|
245
|
+
hostPath: absolutePath,
|
|
246
|
+
containerPath: `/workspaces/${name}`,
|
|
247
|
+
readOnly: Boolean(readOnly)
|
|
248
|
+
};
|
|
249
|
+
return { ...structuredClone(config), workspaces: [...config.workspaces, workspace] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function removeWorkspace(config, identity) {
|
|
253
|
+
validateConfig(config);
|
|
254
|
+
const remaining = config.workspaces.filter(
|
|
255
|
+
(workspace) => workspace.id !== identity && workspace.hostPath !== identity
|
|
256
|
+
);
|
|
257
|
+
if (remaining.length === config.workspaces.length) {
|
|
258
|
+
throw new Error(`Workspace "${identity}" is not registered.`);
|
|
259
|
+
}
|
|
260
|
+
return { ...structuredClone(config), workspaces: remaining };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function credentialProviders() {
|
|
264
|
+
return {
|
|
265
|
+
bundled: [...PROVIDERS.bundled],
|
|
266
|
+
ownerInstalled: [...PROVIDERS.ownerInstalled],
|
|
267
|
+
experimental: [...PROVIDERS.experimental]
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function writeCredential(root, provider, value) {
|
|
272
|
+
assertProvider(provider);
|
|
273
|
+
validateHome(root);
|
|
274
|
+
const normalized = stripTrailingLineEndings(value);
|
|
275
|
+
if (!normalized || normalized.includes("\0")) {
|
|
276
|
+
throw new Error("Credential value cannot be empty or contain null bytes.");
|
|
277
|
+
}
|
|
278
|
+
const credentialsRoot = join(root, "secrets", "providers");
|
|
279
|
+
await mkdir(credentialsRoot, { recursive: true, mode: 0o700 });
|
|
280
|
+
const target = join(credentialsRoot, `${provider}.key`);
|
|
281
|
+
await atomicWrite(target, normalized);
|
|
282
|
+
return target;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function removeCredential(root, provider) {
|
|
286
|
+
assertProvider(provider);
|
|
287
|
+
validateHome(root);
|
|
288
|
+
try {
|
|
289
|
+
await rm(join(root, "secrets", "providers", `${provider}.key`));
|
|
290
|
+
return true;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error?.code === "ENOENT") {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function writeSetupToken(root, token) {
|
|
300
|
+
validateHome(root);
|
|
301
|
+
const normalized = stripTrailingLineEndings(token);
|
|
302
|
+
if (normalized.length < 32 || normalized.length > 500 || /[\0\r\n]/.test(normalized)) {
|
|
303
|
+
throw new Error("SpaceApp setup token must be 32-500 characters without line breaks.");
|
|
304
|
+
}
|
|
305
|
+
const target = join(root, "secrets", "setup-token");
|
|
306
|
+
await atomicWrite(target, normalized);
|
|
307
|
+
return target;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function renderRuntimeEnv(config) {
|
|
311
|
+
validateConfig(config);
|
|
312
|
+
const settings = PROFILE_RUNTIME_SETTINGS[config.profile];
|
|
313
|
+
return [
|
|
314
|
+
`SPACEAPP_IMAGE_TAG=${safeEnv(config.version)}`,
|
|
315
|
+
`SPACEAPP_BIND_HOST=${safeEnv(config.bindHost)}`,
|
|
316
|
+
`SPACEAPP_PORT=${config.port}`,
|
|
317
|
+
`SPACEAPP_TELEMETRY=${config.telemetry}`,
|
|
318
|
+
`SPACEAPP_PROFILE=${safeEnv(config.profile)}`,
|
|
319
|
+
`SPACEAPP_BROWSER_ENABLED=${settings.browserEnabled}`,
|
|
320
|
+
`SPACEAPP_CORE_MEMORY_LIMIT=${settings.coreMemoryLimit}`,
|
|
321
|
+
`SPACEAPP_CORE_CPU_LIMIT=${settings.coreCpuLimit}`,
|
|
322
|
+
`SPACEAPP_CLI_MEMORY_LIMIT=${settings.cliMemoryLimit}`,
|
|
323
|
+
`SPACEAPP_CLI_CPU_LIMIT=${settings.cliCpuLimit}`,
|
|
324
|
+
`SPACEAPP_BROWSER_MEMORY_LIMIT=${settings.browserMemoryLimit}`,
|
|
325
|
+
`SPACEAPP_BROWSER_CPU_LIMIT=${settings.browserCpuLimit}`,
|
|
326
|
+
`SPACEAPP_POSTGRES_MEMORY_LIMIT=${settings.postgresMemoryLimit}`,
|
|
327
|
+
`SPACEAPP_POSTGRES_CPU_LIMIT=${settings.postgresCpuLimit}`,
|
|
328
|
+
`SPACEAPP_TEMPORAL_MEMORY_LIMIT=${settings.temporalMemoryLimit}`,
|
|
329
|
+
`SPACEAPP_TEMPORAL_CPU_LIMIT=${settings.temporalCpuLimit}`,
|
|
330
|
+
""
|
|
331
|
+
].join("\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function renderWorkspaceCompose(config) {
|
|
335
|
+
validateConfig(config);
|
|
336
|
+
const mounts = config.workspaces.flatMap((workspace) => [
|
|
337
|
+
" - type: bind",
|
|
338
|
+
` source: ${JSON.stringify(workspace.hostPath)}`,
|
|
339
|
+
` target: ${JSON.stringify(workspace.containerPath)}`,
|
|
340
|
+
` read_only: ${workspace.readOnly}`
|
|
341
|
+
]);
|
|
342
|
+
const service = mounts.length > 0
|
|
343
|
+
? [" volumes:", ...mounts]
|
|
344
|
+
: [" volumes: []"];
|
|
345
|
+
return [
|
|
346
|
+
"services:",
|
|
347
|
+
" spaceapp-core:",
|
|
348
|
+
...service,
|
|
349
|
+
" spaceapp-cli:",
|
|
350
|
+
...service,
|
|
351
|
+
""
|
|
352
|
+
].join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function composeCommand(action, root, options = {}) {
|
|
356
|
+
validateHome(root);
|
|
357
|
+
const profile = options.profile ?? "standard";
|
|
358
|
+
if (profile !== "light" && profile !== "standard") {
|
|
359
|
+
throw new Error("Compose profile must be light or standard.");
|
|
360
|
+
}
|
|
361
|
+
const base = [
|
|
362
|
+
"compose",
|
|
363
|
+
"--project-name", composeProjectName(root),
|
|
364
|
+
"--project-directory", root,
|
|
365
|
+
"--env-file", join(root, "runtime.env"),
|
|
366
|
+
"-f", join(root, "compose.yml"),
|
|
367
|
+
"-f", join(root, "compose.workspaces.yml"),
|
|
368
|
+
...(profile === "standard" ? ["--profile", "standard"] : [])
|
|
369
|
+
];
|
|
370
|
+
const actions = {
|
|
371
|
+
up: ["up", "-d", "--remove-orphans"],
|
|
372
|
+
down: ["down"],
|
|
373
|
+
status: ["ps"],
|
|
374
|
+
logs: ["logs", "--tail", String(options.lines || 200)],
|
|
375
|
+
pull: ["pull"],
|
|
376
|
+
syncCredentials: ["up", "-d", "--no-deps", "--force-recreate", "spaceapp-cli"],
|
|
377
|
+
installClaude: [
|
|
378
|
+
"run",
|
|
379
|
+
"--rm",
|
|
380
|
+
"--no-deps",
|
|
381
|
+
"--user",
|
|
382
|
+
"10001:10001",
|
|
383
|
+
"--entrypoint",
|
|
384
|
+
"npm",
|
|
385
|
+
"spaceapp-cli",
|
|
386
|
+
"install",
|
|
387
|
+
"--prefix",
|
|
388
|
+
"/var/lib/spaceapp-cli/vendor/claude",
|
|
389
|
+
"--no-audit",
|
|
390
|
+
"--no-fund",
|
|
391
|
+
"@anthropic-ai/claude-code@2.1.206"
|
|
392
|
+
],
|
|
393
|
+
backup: ["exec", "-T", "--user", "0:0", "spaceapp-core", "node", "scripts/portable-backup.mjs"],
|
|
394
|
+
stopForRestore: ["stop", "spaceapp-core", "spaceapp-cli", "spaceapp-browser"],
|
|
395
|
+
resetOwnerPassword: [
|
|
396
|
+
"exec",
|
|
397
|
+
"-T",
|
|
398
|
+
"--user",
|
|
399
|
+
"10001:10001",
|
|
400
|
+
"spaceapp-core",
|
|
401
|
+
"node",
|
|
402
|
+
"scripts/reset-owner-password.mjs",
|
|
403
|
+
"--stdin"
|
|
404
|
+
],
|
|
405
|
+
rotateOwnerSetupToken: [
|
|
406
|
+
"exec",
|
|
407
|
+
"-T",
|
|
408
|
+
"--user",
|
|
409
|
+
"10001:10001",
|
|
410
|
+
"spaceapp-core",
|
|
411
|
+
"node",
|
|
412
|
+
"scripts/rotate-owner-setup-token.mjs",
|
|
413
|
+
"--stdin"
|
|
414
|
+
],
|
|
415
|
+
purge: ["down", "--volumes", "--remove-orphans"]
|
|
416
|
+
};
|
|
417
|
+
let selected = actions[action];
|
|
418
|
+
if (action === "restore") {
|
|
419
|
+
if (typeof options.backupId !== "string" || !BACKUP_ID_PATTERN.test(options.backupId)) {
|
|
420
|
+
throw new Error("A valid SpaceApp backup id is required for restore.");
|
|
421
|
+
}
|
|
422
|
+
selected = [
|
|
423
|
+
"run",
|
|
424
|
+
"--rm",
|
|
425
|
+
"--no-deps",
|
|
426
|
+
"--user",
|
|
427
|
+
"0:0",
|
|
428
|
+
"--env",
|
|
429
|
+
"SPACE_DATABASE_URL_FILE=/run/secrets/database-url",
|
|
430
|
+
"--entrypoint",
|
|
431
|
+
"node",
|
|
432
|
+
"spaceapp-core",
|
|
433
|
+
"scripts/portable-restore.mjs",
|
|
434
|
+
"--input",
|
|
435
|
+
"/backups",
|
|
436
|
+
"--backup-id",
|
|
437
|
+
options.backupId,
|
|
438
|
+
"--confirm",
|
|
439
|
+
"RESTORE"
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
if (!selected) {
|
|
443
|
+
throw new Error(`Unsupported Compose action "${action}".`);
|
|
444
|
+
}
|
|
445
|
+
return { command: "docker", args: [...base, ...selected] };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function composeProjectName(root) {
|
|
449
|
+
validateHome(root);
|
|
450
|
+
return `spaceapp-${createHash("sha256").update(resolve(root)).digest("hex").slice(0, 12)}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function selectLatestBackupId(root) {
|
|
454
|
+
validateHome(root);
|
|
455
|
+
const entries = await readdir(join(root, "backups"), { withFileTypes: true });
|
|
456
|
+
const backupId = entries
|
|
457
|
+
.filter((entry) => entry.isDirectory() && BACKUP_ID_PATTERN.test(entry.name))
|
|
458
|
+
.map((entry) => entry.name)
|
|
459
|
+
.sort()
|
|
460
|
+
.at(-1);
|
|
461
|
+
if (!backupId) {
|
|
462
|
+
throw new Error('No portable backup exists. Run "spaceapp backup" before restore.');
|
|
463
|
+
}
|
|
464
|
+
return backupId;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function writeRuntimeFiles(root, config) {
|
|
468
|
+
validateHome(root);
|
|
469
|
+
validateConfig(config);
|
|
470
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
471
|
+
await atomicWrite(join(root, "runtime.env"), renderRuntimeEnv(config));
|
|
472
|
+
await atomicWrite(join(root, "compose.workspaces.yml"), renderWorkspaceCompose(config));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function initializeInstallation(root, {
|
|
476
|
+
version,
|
|
477
|
+
templateDir = defaultTemplateDir(),
|
|
478
|
+
profile
|
|
479
|
+
}) {
|
|
480
|
+
validateHome(root);
|
|
481
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
482
|
+
await Promise.all([
|
|
483
|
+
mkdir(join(root, "backups"), { recursive: true, mode: 0o700 }),
|
|
484
|
+
mkdir(join(root, "secrets", "providers"), { recursive: true, mode: 0o700 })
|
|
485
|
+
]);
|
|
486
|
+
let config;
|
|
487
|
+
try {
|
|
488
|
+
config = await loadConfig(root);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (error?.code !== "ENOENT") {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
config = createDefaultConfig({ version, profile: profile ?? "standard" });
|
|
494
|
+
}
|
|
495
|
+
if (profile !== undefined) {
|
|
496
|
+
const resolvedProfile = resolveInstallProfile(profile, AUTO_LIGHT_MEMORY_THRESHOLD_BYTES);
|
|
497
|
+
config = { ...config, profile: resolvedProfile };
|
|
498
|
+
}
|
|
499
|
+
await saveConfig(root, config);
|
|
500
|
+
await copyFile(join(templateDir, "compose.yml"), join(root, "compose.yml"));
|
|
501
|
+
await chmod(join(root, "compose.yml"), 0o600);
|
|
502
|
+
await writeRuntimeFiles(root, config);
|
|
503
|
+
|
|
504
|
+
const postgresPasswordPath = join(root, "secrets", "postgres-password");
|
|
505
|
+
let postgresPassword;
|
|
506
|
+
try {
|
|
507
|
+
postgresPassword = (await readFile(postgresPasswordPath, "utf8")).trim();
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error?.code !== "ENOENT") {
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
postgresPassword = randomBytes(32).toString("base64url");
|
|
513
|
+
await atomicWrite(postgresPasswordPath, postgresPassword);
|
|
514
|
+
}
|
|
515
|
+
const databaseUrlPath = join(root, "secrets", "database-url");
|
|
516
|
+
try {
|
|
517
|
+
await stat(databaseUrlPath);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (error?.code !== "ENOENT") {
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
const encodedPassword = encodeURIComponent(postgresPassword);
|
|
523
|
+
await atomicWrite(
|
|
524
|
+
databaseUrlPath,
|
|
525
|
+
`postgresql://spaceapp:${encodedPassword}@postgres:5432/spaceapp`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const sessionSecretPath = join(root, "secrets", "session-secret");
|
|
530
|
+
try {
|
|
531
|
+
await stat(sessionSecretPath);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (error?.code !== "ENOENT") {
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
await atomicWrite(sessionSecretPath, randomBytes(48).toString("base64url"));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const setupTokenPath = join(root, "secrets", "setup-token");
|
|
540
|
+
let setupToken = null;
|
|
541
|
+
try {
|
|
542
|
+
await stat(setupTokenPath);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error?.code !== "ENOENT") {
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
setupToken = randomBytes(32).toString("base64url");
|
|
548
|
+
await atomicWrite(setupTokenPath, setupToken);
|
|
549
|
+
}
|
|
550
|
+
return { config, setupToken };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function validateWorkspace(workspace) {
|
|
554
|
+
if (!workspace || typeof workspace !== "object") {
|
|
555
|
+
throw new Error("Invalid workspace entry.");
|
|
556
|
+
}
|
|
557
|
+
if (!/^[a-z0-9._-]+-[a-f0-9]{8}$/.test(workspace.id)) {
|
|
558
|
+
throw new Error("Invalid workspace id.");
|
|
559
|
+
}
|
|
560
|
+
if (!isAbsolute(workspace.hostPath) || !workspace.containerPath.startsWith("/workspaces/")) {
|
|
561
|
+
throw new Error("Workspace paths must be absolute.");
|
|
562
|
+
}
|
|
563
|
+
if (typeof workspace.readOnly !== "boolean") {
|
|
564
|
+
throw new Error("Workspace readOnly must be boolean.");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function validateHome(root) {
|
|
569
|
+
if (!root || !isAbsolute(root)) {
|
|
570
|
+
throw new Error("SpaceApp home must be an absolute path.");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function assertVersion(version) {
|
|
575
|
+
if (typeof version !== "string" || !VERSION_PATTERN.test(version)) {
|
|
576
|
+
throw new Error(`Invalid SpaceApp version "${version}".`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function assertProvider(provider) {
|
|
581
|
+
if (!ALL_PROVIDERS.has(provider)) {
|
|
582
|
+
throw new Error(`Unsupported credential provider "${provider}".`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function safeEnv(value) {
|
|
587
|
+
if (String(value).includes("\n") || String(value).includes("\r")) {
|
|
588
|
+
throw new Error("Runtime settings cannot contain newlines.");
|
|
589
|
+
}
|
|
590
|
+
return String(value);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function formatGibibytes(bytes) {
|
|
594
|
+
return Math.floor((bytes / 1024 ** 3) * 10) / 10;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function atomicWrite(target, value) {
|
|
598
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
599
|
+
const temporary = `${target}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
600
|
+
try {
|
|
601
|
+
await writeFile(temporary, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
602
|
+
await chmod(temporary, 0o600);
|
|
603
|
+
await rename(temporary, target);
|
|
604
|
+
await chmod(target, 0o600);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function defaultTemplateDir() {
|
|
612
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../templates");
|
|
613
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const DEFAULT_WORKSPACE_NAME = "workspace";
|
|
2
|
+
|
|
3
|
+
export function normalizeWorkspaceName(value) {
|
|
4
|
+
const input = String(value).toLowerCase();
|
|
5
|
+
let normalized = "";
|
|
6
|
+
let insideInvalidRun = false;
|
|
7
|
+
|
|
8
|
+
for (const character of input) {
|
|
9
|
+
if (isWorkspaceNameCharacter(character)) {
|
|
10
|
+
normalized += character;
|
|
11
|
+
insideInvalidRun = false;
|
|
12
|
+
} else if (!insideInvalidRun) {
|
|
13
|
+
normalized += "-";
|
|
14
|
+
insideInvalidRun = true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let start = 0;
|
|
19
|
+
let end = normalized.length;
|
|
20
|
+
while (start < end && normalized[start] === "-") start += 1;
|
|
21
|
+
while (end > start && normalized[end - 1] === "-") end -= 1;
|
|
22
|
+
return normalized.slice(start, end) || DEFAULT_WORKSPACE_NAME;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stripTrailingLineEndings(value) {
|
|
26
|
+
const input = String(value);
|
|
27
|
+
let end = input.length;
|
|
28
|
+
while (end > 0 && (input[end - 1] === "\r" || input[end - 1] === "\n")) {
|
|
29
|
+
end -= 1;
|
|
30
|
+
}
|
|
31
|
+
return input.slice(0, end);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isWorkspaceNameCharacter(character) {
|
|
35
|
+
if (character === "." || character === "_" || character === "-") return true;
|
|
36
|
+
const code = character.charCodeAt(0);
|
|
37
|
+
return (code >= 48 && code <= 57) || (code >= 97 && code <= 122);
|
|
38
|
+
}
|