atlas-core 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.
@@ -0,0 +1,967 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { PACKAGE_IMAGE, PACKAGE_NAME, PACKAGE_VERSION } from "./package-metadata.js";
8
+ const PROJECT_NAME = "atlas_core_production";
9
+ const POSTGRES_VOLUME = `${PROJECT_NAME}_postgres_data`;
10
+ const MINIO_VOLUME = `${PROJECT_NAME}_minio_data`;
11
+ const API_CONTAINER = `${PROJECT_NAME}_api`;
12
+ const POSTGRES_CONTAINER = `${PROJECT_NAME}_postgres`;
13
+ const MINIO_CONTAINER = `${PROJECT_NAME}_minio`;
14
+ const MINIO_INIT_CONTAINER = `${PROJECT_NAME}_minio_init`;
15
+ const INIT_LOCK_NETWORK = `${PROJECT_NAME}_init_lock`;
16
+ const REQUIRED_SERVICES = new Set(["api", "minio", "postgres"]);
17
+ const COMPOSE_VARIABLES = [
18
+ "API_AUTH_KEY",
19
+ "ATLAS_ADMIN_PASSWORD",
20
+ "ATLAS_CORE_IMAGE",
21
+ "CORS_ORIGINS",
22
+ "CORS_ORIGIN_PATTERNS",
23
+ "DATABASE_MAX_OVERFLOW",
24
+ "DATABASE_POOL_IDLE_TIMEOUT",
25
+ "DATABASE_POOL_PRE_PING",
26
+ "DATABASE_POOL_RECYCLE",
27
+ "DATABASE_POOL_SIZE",
28
+ "DATABASE_POOL_TIMEOUT",
29
+ "MAX_UPLOAD_SIZE_MB",
30
+ "MAX_VIEW_SIZE_MB",
31
+ "MINIO_BUCKET",
32
+ "MINIO_ROOT_PASSWORD",
33
+ "MINIO_ROOT_USER",
34
+ "POSTGRES_PASSWORD",
35
+ "TRUSTED_PROXY_CIDRS"
36
+ ];
37
+ const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
38
+ const SUPPORTED_ARCHITECTURES = new Set(["arm64", "x64"]);
39
+ const CONFIG_SCHEMA = 1;
40
+ const COMPOSE_WAIT_SECONDS = "120";
41
+ const MINIMUM_COMPOSE_VERSION = [2, 17, 0];
42
+ const UNRELEASED_IMAGE = "ghcr.io/the-drunken-coder/atlas-core:unreleased";
43
+ const SUPPORTED_DOCKER_ARCHITECTURES = new Set(["amd64", "arm64", "aarch64", "x86_64"]);
44
+ const usage = `Atlas Core ${PACKAGE_VERSION}
45
+
46
+ Usage:
47
+ atlas-core init
48
+ atlas-core start
49
+ atlas-core stop
50
+ atlas-core restart
51
+ atlas-core status
52
+ atlas-core logs [core|postgres|minio] [--follow]
53
+ atlas-core doctor
54
+ atlas-core version
55
+ atlas-core help
56
+
57
+ Atlas Core stores durable configuration in ~/.atlas/core by default.
58
+ Set ATLAS_CORE_HOME to use a different directory.
59
+ `;
60
+ class UsageError extends Error {
61
+ constructor(message) {
62
+ super(message);
63
+ this.name = "UsageError";
64
+ }
65
+ }
66
+ class ProcessCommandRunner {
67
+ async run(command, args, options = {}) {
68
+ return await new Promise((resolve, reject) => {
69
+ const child = spawn(command, args, {
70
+ cwd: options.cwd,
71
+ env: options.env,
72
+ stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"]
73
+ });
74
+ let stdout = "";
75
+ let stderr = "";
76
+ child.stdout?.setEncoding("utf8");
77
+ child.stderr?.setEncoding("utf8");
78
+ child.stdout?.on("data", (chunk) => {
79
+ stdout += chunk;
80
+ });
81
+ child.stderr?.on("data", (chunk) => {
82
+ stderr += chunk;
83
+ });
84
+ child.once("error", reject);
85
+ child.once("close", (status) => {
86
+ resolve({ status: status ?? 1, stdout, stderr });
87
+ });
88
+ });
89
+ }
90
+ }
91
+ class AtlasCoreDeployment {
92
+ #configDir;
93
+ #envFile;
94
+ #stateFile;
95
+ #initLockFile;
96
+ #composeFile;
97
+ #initComposeFile;
98
+ #runner;
99
+ #stdout;
100
+ #stderr;
101
+ #env;
102
+ #platform;
103
+ #architecture;
104
+ #nodeVersion;
105
+ #now;
106
+ #createSecret;
107
+ #imageReference;
108
+ constructor(context) {
109
+ this.#configDir = resolveConfigDirectory(context.env.ATLAS_CORE_HOME, context.homeDir);
110
+ this.#envFile = join(this.#configDir, ".env");
111
+ this.#stateFile = join(this.#configDir, "state.json");
112
+ this.#initLockFile = join(this.#configDir, ".init.lock");
113
+ this.#composeFile = join(context.packageRoot, "assets", "docker-compose.yml");
114
+ this.#initComposeFile = join(context.packageRoot, "assets", "docker-compose.init.yml");
115
+ this.#runner = context.runner;
116
+ this.#stdout = context.stdout;
117
+ this.#stderr = context.stderr;
118
+ this.#env = context.env;
119
+ this.#platform = context.platform;
120
+ this.#architecture = context.architecture;
121
+ this.#nodeVersion = context.nodeVersion;
122
+ this.#now = context.now;
123
+ this.#createSecret = context.createSecret;
124
+ this.#imageReference = context.imageReference;
125
+ }
126
+ async init() {
127
+ const dockerEngineId = await this.#preflight();
128
+ this.#prepareConfigDirectory();
129
+ this.#acquireInitLock();
130
+ try {
131
+ await this.#acquireDockerInitLock(dockerEngineId);
132
+ try {
133
+ await this.#initialize(dockerEngineId);
134
+ }
135
+ finally {
136
+ await this.#releaseDockerInitLock();
137
+ }
138
+ }
139
+ finally {
140
+ this.#releaseInitLock();
141
+ }
142
+ }
143
+ async #initialize(dockerEngineId) {
144
+ const hasEnv = existsSync(this.#envFile);
145
+ const hasState = existsSync(this.#stateFile);
146
+ if (hasEnv)
147
+ this.#assertPrivateFile(this.#envFile);
148
+ if (hasState)
149
+ this.#assertPrivateFile(this.#stateFile);
150
+ const existingState = this.#readState();
151
+ if (hasState && !existingState) {
152
+ throw new Error(`${this.#stateFile} is invalid. Initialization stopped so existing storage is not adopted.`);
153
+ }
154
+ if (existingState && existingState.schema !== CONFIG_SCHEMA) {
155
+ throw new Error(`Atlas Core state schema ${existingState.schema} is not supported by this CLI.`);
156
+ }
157
+ if (existingState?.phase === "ready") {
158
+ if (!hasEnv)
159
+ throw new Error(`Atlas Core state exists without ${this.#envFile}. Restore the matching credentials.`);
160
+ this.#assertStateMatchesRuntime(existingState, dockerEngineId);
161
+ this.#stdout.write(`Atlas Core is already initialized at ${this.#configDir}.\n`);
162
+ return;
163
+ }
164
+ if (existingState)
165
+ this.#assertStateMatchesRuntime(existingState, dockerEngineId);
166
+ if (hasEnv && !existingState) {
167
+ throw new Error(`Atlas Core found ${this.#envFile} without matching initialization state. ` +
168
+ "Initialization stopped so arbitrary credentials cannot adopt existing storage.");
169
+ }
170
+ const [hasPostgres, hasMinio, hasApiContainer, hasPostgresContainer, hasMinioContainer, hasMinioInitContainer] = await Promise.all([
171
+ this.#volumeExists(POSTGRES_VOLUME),
172
+ this.#volumeExists(MINIO_VOLUME),
173
+ this.#containerExists(API_CONTAINER),
174
+ this.#containerExists(POSTGRES_CONTAINER),
175
+ this.#containerExists(MINIO_CONTAINER),
176
+ this.#containerExists(MINIO_INIT_CONTAINER)
177
+ ]);
178
+ if (!hasEnv &&
179
+ (hasPostgres || hasMinio || hasApiContainer || hasPostgresContainer || hasMinioContainer || hasMinioInitContainer)) {
180
+ throw new Error("Atlas Core found containers or durable volumes without matching CLI configuration. " +
181
+ "Initialization stopped so existing data cannot be adopted with new credentials.");
182
+ }
183
+ if (hasEnv && (hasPostgres || hasApiContainer || hasPostgresContainer || hasMinioInitContainer)) {
184
+ throw new Error("Atlas Core found an incomplete initialization with a PostgreSQL volume. " +
185
+ "Initialization stopped because the deployment is no longer provably new.");
186
+ }
187
+ const initializingState = this.#writeInitializingState(dockerEngineId, existingState);
188
+ if (!hasEnv)
189
+ this.#writeConfiguration();
190
+ if (!hasMinio)
191
+ await this.#createVolume(MINIO_VOLUME, "minio_data");
192
+ let startedMinio = false;
193
+ try {
194
+ this.#stdout.write("Provisioning the new durable MinIO store...\n");
195
+ startedMinio = true;
196
+ await this.#runInitComposeChecked(["up", "-d", "--wait", "--wait-timeout", COMPOSE_WAIT_SECONDS, "minio"]);
197
+ await this.#runInitComposeChecked([
198
+ "exec",
199
+ "-T",
200
+ "minio",
201
+ "sh",
202
+ "-c",
203
+ 'mc alias set local http://127.0.0.1:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null'
204
+ ]);
205
+ const bucket = this.#readConfigValue("MINIO_BUCKET") ?? "atlas-media";
206
+ await this.#runInitComposeChecked(["exec", "-T", "minio", "mc", "mb", "--ignore-existing", `local/${bucket}`]);
207
+ await this.#runInitComposeChecked(["exec", "-T", "minio", "mc", "anonymous", "set", "none", `local/${bucket}`]);
208
+ await this.#runInitComposeChecked(["down"]);
209
+ startedMinio = false;
210
+ this.#writeReadyState(initializingState);
211
+ }
212
+ catch (error) {
213
+ if (startedMinio) {
214
+ const cleanup = await this.#runInitCompose(["down"]);
215
+ if (cleanup.status !== 0) {
216
+ throw new Error(`${errorMessage(error)} Cleanup also failed: ${errorMessage(commandFailure("docker compose down", cleanup))}`);
217
+ }
218
+ }
219
+ throw error;
220
+ }
221
+ this.#stdout.write(`Atlas Core initialized at ${this.#configDir}.\n`);
222
+ this.#stdout.write(`Credentials are stored in ${this.#envFile} with owner-only permissions.\n`);
223
+ this.#stdout.write("Run atlas-core start to start the deployment.\n");
224
+ }
225
+ async start() {
226
+ const state = this.#requireInitialized();
227
+ const dockerEngineId = await this.#preflight();
228
+ this.#assertStateMatchesRuntime(state, dockerEngineId);
229
+ this.#requirePublishedImage();
230
+ const needsPostgresVolume = await this.#assertStartIsSafe(state);
231
+ if (needsPostgresVolume)
232
+ await this.#createVolume(POSTGRES_VOLUME, "postgres_data");
233
+ const attemptedState = this.#recordStartAttempt(state);
234
+ this.#stdout.write(`Starting Atlas Core ${PACKAGE_VERSION}...\n`);
235
+ await this.#runComposeChecked(["up", "-d", "--pull", "always", "--wait", "--wait-timeout", COMPOSE_WAIT_SECONDS]);
236
+ this.#recordStarted(attemptedState);
237
+ this.#stdout.write("Atlas Core is ready.\n");
238
+ this.#stdout.write("API: http://127.0.0.1:8000\n");
239
+ this.#stdout.write("MinIO UI: http://127.0.0.1:9001\n");
240
+ }
241
+ async stop() {
242
+ const state = this.#requireInitialized();
243
+ const dockerEngineId = await this.#preflight();
244
+ this.#assertStateMatchesRuntime(state, dockerEngineId);
245
+ await this.#runComposeChecked(["down"]);
246
+ this.#stdout.write("Atlas Core stopped. Durable volumes were preserved.\n");
247
+ }
248
+ async restart() {
249
+ const state = this.#requireInitialized();
250
+ const dockerEngineId = await this.#preflight();
251
+ this.#assertStateMatchesRuntime(state, dockerEngineId);
252
+ this.#requirePublishedImage();
253
+ const needsPostgresVolume = await this.#assertStartIsSafe(state);
254
+ if (needsPostgresVolume)
255
+ await this.#createVolume(POSTGRES_VOLUME, "postgres_data");
256
+ const attemptedState = this.#recordStartAttempt(state);
257
+ await this.#runComposeChecked(["pull"]);
258
+ await this.#runComposeChecked(["down"]);
259
+ await this.#runComposeChecked(["up", "-d", "--pull", "never", "--wait", "--wait-timeout", COMPOSE_WAIT_SECONDS]);
260
+ this.#recordStarted(attemptedState);
261
+ this.#stdout.write(`Atlas Core ${PACKAGE_VERSION} restarted and is ready.\n`);
262
+ }
263
+ async status() {
264
+ const state = this.#requireInitialized();
265
+ const dockerEngineId = await this.#preflight();
266
+ this.#assertStateMatchesRuntime(state, dockerEngineId);
267
+ const result = await this.#runCompose(["ps", "--all", "--format", "json"]);
268
+ if (result.status !== 0)
269
+ throw commandFailure("docker compose ps", result);
270
+ const services = parseComposeServiceStates(result.stdout);
271
+ const failures = [...REQUIRED_SERVICES].flatMap((service) => {
272
+ const current = services.find((candidate) => candidate.Service === service);
273
+ if (!current)
274
+ return [`${service} is missing`];
275
+ if (current.State !== "running")
276
+ return [`${service} is ${current.State || "in an unknown state"}`];
277
+ if (current.Health !== "healthy")
278
+ return [`${service} is ${current.Health || "not reporting health"}`];
279
+ return [];
280
+ });
281
+ if (failures.length > 0) {
282
+ this.#stderr.write(`Atlas Core is not ready: ${failures.join(", ")}.\n`);
283
+ return false;
284
+ }
285
+ this.#stdout.write("Atlas Core is running.\n");
286
+ return true;
287
+ }
288
+ async logs(service, follow) {
289
+ const state = this.#requireInitialized();
290
+ const dockerEngineId = await this.#preflight();
291
+ this.#assertStateMatchesRuntime(state, dockerEngineId);
292
+ const args = ["logs", "--tail", "200"];
293
+ if (follow)
294
+ args.push("--follow");
295
+ if (service)
296
+ args.push(service);
297
+ const result = await this.#runCompose(args, true);
298
+ if (result.status !== 0)
299
+ throw commandFailure("docker compose logs", result);
300
+ }
301
+ async doctor() {
302
+ const checks = [
303
+ {
304
+ label: "platform",
305
+ check: async () => {
306
+ if (!SUPPORTED_PLATFORMS.has(this.#platform))
307
+ throw new Error(`${this.#platform} is not supported yet`);
308
+ if (!SUPPORTED_ARCHITECTURES.has(this.#architecture)) {
309
+ throw new Error(`${this.#architecture} is not supported yet`);
310
+ }
311
+ return `${this.#platform}/${this.#architecture}`;
312
+ }
313
+ },
314
+ {
315
+ label: "Node.js",
316
+ check: async () => {
317
+ assertNodeVersion(this.#nodeVersion);
318
+ return this.#nodeVersion;
319
+ }
320
+ },
321
+ {
322
+ label: "Docker",
323
+ check: async () => oneLine(await this.#checkCommand("docker", ["--version"]))
324
+ },
325
+ {
326
+ label: "Docker Compose",
327
+ check: async () => {
328
+ const version = oneLine(await this.#checkCommand("docker", ["compose", "version", "--short"]));
329
+ assertComposeVersion(version);
330
+ return version;
331
+ }
332
+ },
333
+ {
334
+ label: "Docker daemon",
335
+ check: async () => {
336
+ const runtime = await this.#dockerRuntime();
337
+ return `${runtime.engineId} (${runtime.operatingSystem}/${runtime.architecture})`;
338
+ }
339
+ },
340
+ {
341
+ label: "configuration",
342
+ check: async () => {
343
+ const state = this.#requireInitialized();
344
+ const runtime = await this.#dockerRuntime();
345
+ this.#assertStateMatchesRuntime(state, runtime.engineId);
346
+ await this.#runComposeChecked(["config", "--quiet"]);
347
+ return this.#configDir;
348
+ }
349
+ }
350
+ ];
351
+ let healthy = true;
352
+ for (const item of checks) {
353
+ try {
354
+ this.#stdout.write(`[ok] ${item.label}: ${await item.check()}\n`);
355
+ }
356
+ catch (error) {
357
+ healthy = false;
358
+ this.#stderr.write(`[fail] ${item.label}: ${errorMessage(error)}\n`);
359
+ }
360
+ }
361
+ return healthy;
362
+ }
363
+ async #preflight() {
364
+ if (!SUPPORTED_PLATFORMS.has(this.#platform)) {
365
+ throw new Error(`Atlas Core supports macOS and Linux. Detected ${this.#platform}.`);
366
+ }
367
+ if (!SUPPORTED_ARCHITECTURES.has(this.#architecture)) {
368
+ throw new Error(`Atlas Core supports arm64 and x64 hosts. Detected ${this.#architecture}.`);
369
+ }
370
+ assertNodeVersion(this.#nodeVersion);
371
+ await this.#checkCommand("docker", ["--version"]);
372
+ const composeVersion = oneLine(await this.#checkCommand("docker", ["compose", "version", "--short"]));
373
+ assertComposeVersion(composeVersion);
374
+ return (await this.#dockerRuntime()).engineId;
375
+ }
376
+ async #dockerRuntime() {
377
+ const context = oneLine(await this.#checkCommand("docker", ["context", "show"]));
378
+ if (!context)
379
+ throw new Error("Docker did not report an active context.");
380
+ const contextHost = oneLine(await this.#checkCommand("docker", [
381
+ "context",
382
+ "inspect",
383
+ context,
384
+ "--format",
385
+ '{{(index .Endpoints "docker").Host}}'
386
+ ]));
387
+ const configuredContext = this.#env.DOCKER_CONTEXT?.trim();
388
+ const dockerHost = configuredContext ? contextHost : this.#env.DOCKER_HOST?.trim() || contextHost;
389
+ if (!dockerHost.startsWith("unix://")) {
390
+ throw new Error(`Atlas Core requires a local Docker daemon over a Unix socket. Context ${context} uses ${dockerHost || "no endpoint"}.`);
391
+ }
392
+ const raw = await this.#checkCommand("docker", ["info", "--format", "{{json .}}"]);
393
+ let info;
394
+ try {
395
+ info = JSON.parse(raw);
396
+ }
397
+ catch {
398
+ throw new Error("Docker returned invalid daemon information.");
399
+ }
400
+ if (!isDockerInfo(info)) {
401
+ throw new Error("Docker daemon information is missing its ID, operating system, or architecture.");
402
+ }
403
+ if (info.OSType !== "linux") {
404
+ throw new Error(`Atlas Core requires a Linux Docker daemon. Detected ${info.OSType}.`);
405
+ }
406
+ if (!SUPPORTED_DOCKER_ARCHITECTURES.has(info.Architecture)) {
407
+ throw new Error(`Atlas Core supports amd64 and arm64 Docker daemons. Detected ${info.Architecture}.`);
408
+ }
409
+ return { architecture: info.Architecture, engineId: info.ID, operatingSystem: info.OSType };
410
+ }
411
+ async #checkCommand(command, args) {
412
+ const result = await this.#runner.run(command, args, { env: this.#env });
413
+ if (result.status !== 0)
414
+ throw commandFailure([command, ...args].join(" "), result);
415
+ return result.stdout || result.stderr;
416
+ }
417
+ #acquireInitLock() {
418
+ try {
419
+ writePrivateFile(this.#initLockFile, `${JSON.stringify({ pid: process.pid }, null, 2)}\n`, this.#platform, true);
420
+ return;
421
+ }
422
+ catch (error) {
423
+ if (!isNodeError(error) || error.code !== "EEXIST")
424
+ throw error;
425
+ }
426
+ this.#assertPrivateFile(this.#initLockFile);
427
+ let owner;
428
+ try {
429
+ owner = JSON.parse(readFileSync(this.#initLockFile, "utf8"));
430
+ }
431
+ catch {
432
+ owner = undefined;
433
+ }
434
+ const ownerDescription = isInitLock(owner) ? ` by PID ${owner.pid}` : "";
435
+ throw new Error(`Atlas Core initialization is locked${ownerDescription} at ${this.#initLockFile}. ` +
436
+ "If no atlas-core init process is running, remove that file and run init again.");
437
+ }
438
+ #releaseInitLock() {
439
+ try {
440
+ unlinkSync(this.#initLockFile);
441
+ }
442
+ catch (error) {
443
+ if (!isNodeError(error) || error.code !== "ENOENT")
444
+ throw error;
445
+ }
446
+ }
447
+ async #acquireDockerInitLock(dockerEngineId) {
448
+ const labels = {
449
+ "io.atlas.core.engine": dockerEngineId,
450
+ "io.atlas.core.lock": "initialization",
451
+ "io.atlas.core.project": PROJECT_NAME
452
+ };
453
+ const args = ["network", "create"];
454
+ for (const [name, value] of Object.entries(labels))
455
+ args.push("--label", `${name}=${value}`);
456
+ args.push(INIT_LOCK_NETWORK);
457
+ const result = await this.#runner.run("docker", args, { env: this.#env });
458
+ if (result.status === 0)
459
+ return;
460
+ const inspection = await this.#runner.run("docker", ["network", "inspect", "--format", "{{json .Labels}}", INIT_LOCK_NETWORK], { env: this.#env });
461
+ if (inspection.status !== 0)
462
+ throw commandFailure(`docker ${args.join(" ")}`, result);
463
+ this.#assertResourceLabels("initialization lock", INIT_LOCK_NETWORK, inspection.stdout, labels);
464
+ throw new Error(`Atlas Core initialization is already locked on Docker engine ${dockerEngineId}. ` +
465
+ `If no atlas-core init process is running, remove ${INIT_LOCK_NETWORK} with docker network rm and run init again.`);
466
+ }
467
+ async #releaseDockerInitLock() {
468
+ const result = await this.#runner.run("docker", ["network", "rm", INIT_LOCK_NETWORK], { env: this.#env });
469
+ if (result.status !== 0)
470
+ throw commandFailure(`docker network rm ${INIT_LOCK_NETWORK}`, result);
471
+ }
472
+ async #volumeExists(name) {
473
+ const volume = name === POSTGRES_VOLUME ? "postgres_data" : "minio_data";
474
+ return await this.#ownedResourceExists("volume", name, {
475
+ "com.docker.compose.project": PROJECT_NAME,
476
+ "com.docker.compose.volume": volume
477
+ });
478
+ }
479
+ async #createVolume(name, volume) {
480
+ await this.#checkCommand("docker", [
481
+ "volume",
482
+ "create",
483
+ "--label",
484
+ `com.docker.compose.project=${PROJECT_NAME}`,
485
+ "--label",
486
+ `com.docker.compose.volume=${volume}`,
487
+ name
488
+ ]);
489
+ if (!(await this.#volumeExists(name))) {
490
+ throw new Error(`Docker created ${name}, but Atlas Core could not verify the volume.`);
491
+ }
492
+ }
493
+ async #containerExists(name) {
494
+ const service = name === API_CONTAINER
495
+ ? "api"
496
+ : name === POSTGRES_CONTAINER
497
+ ? "postgres"
498
+ : name === MINIO_INIT_CONTAINER
499
+ ? "minio-init"
500
+ : "minio";
501
+ return await this.#ownedResourceExists("container", name, {
502
+ "com.docker.compose.project": PROJECT_NAME,
503
+ "com.docker.compose.service": service
504
+ });
505
+ }
506
+ async #ownedResourceExists(kind, name, expectedLabels) {
507
+ const result = await this.#runner.run("docker", [kind, "inspect", "--format", "{{json .Labels}}", name], {
508
+ env: this.#env
509
+ });
510
+ if (result.status !== 0) {
511
+ if (new RegExp(`no such ${kind}`, "i").test(result.stderr || result.stdout))
512
+ return false;
513
+ throw commandFailure(`docker ${kind} inspect ${name}`, result);
514
+ }
515
+ this.#assertResourceLabels(kind, name, result.stdout, expectedLabels);
516
+ return true;
517
+ }
518
+ #assertResourceLabels(kind, name, stdout, expectedLabels) {
519
+ let labels;
520
+ try {
521
+ labels = JSON.parse(stdout);
522
+ }
523
+ catch {
524
+ throw new Error(`Docker returned invalid ownership labels for ${kind} ${name}.`);
525
+ }
526
+ if (typeof labels !== "object" || labels === null) {
527
+ throw new Error(`Atlas Core found ${kind} ${name} without ownership labels.`);
528
+ }
529
+ const record = labels;
530
+ const mismatch = Object.entries(expectedLabels).find(([key, value]) => record[key] !== value);
531
+ if (mismatch) {
532
+ throw new Error(`Atlas Core found ${kind} ${name} without the expected ${mismatch[0]}=${mismatch[1]} ownership label.`);
533
+ }
534
+ }
535
+ async #assertStartIsSafe(state) {
536
+ const [hasPostgres, hasMinio] = await Promise.all([
537
+ this.#volumeExists(POSTGRES_VOLUME),
538
+ this.#volumeExists(MINIO_VOLUME)
539
+ ]);
540
+ if (!hasMinio || ((state.startAttemptedAt !== undefined || state.startedAt !== undefined) && !hasPostgres)) {
541
+ throw new Error("Atlas Core durable storage is missing. Start stopped so Docker Compose cannot replace it with an empty volume.");
542
+ }
543
+ return !hasPostgres;
544
+ }
545
+ #writeConfiguration() {
546
+ const contents = [
547
+ "# Generated by atlas-core init. Keep this file private and back it up securely.",
548
+ `POSTGRES_PASSWORD=${this.#createSecret()}`,
549
+ "MINIO_ROOT_USER=atlas",
550
+ `MINIO_ROOT_PASSWORD=${this.#createSecret()}`,
551
+ "MINIO_BUCKET=atlas-media",
552
+ `API_AUTH_KEY=${this.#createSecret()}`,
553
+ `ATLAS_ADMIN_PASSWORD=${this.#createSecret()}`,
554
+ "CORS_ORIGINS=https://atlasinterface.com",
555
+ "CORS_ORIGIN_PATTERNS=https://*.atlas-je0.pages.dev",
556
+ "TRUSTED_PROXY_CIDRS=",
557
+ "",
558
+ "# Optional tuning",
559
+ "DATABASE_POOL_SIZE=5",
560
+ "DATABASE_MAX_OVERFLOW=10",
561
+ "DATABASE_POOL_RECYCLE=3600",
562
+ "DATABASE_POOL_TIMEOUT=30",
563
+ "DATABASE_POOL_IDLE_TIMEOUT=600",
564
+ "DATABASE_POOL_PRE_PING=true",
565
+ "MAX_UPLOAD_SIZE_MB=100",
566
+ "MAX_VIEW_SIZE_MB=10",
567
+ ""
568
+ ].join("\n");
569
+ try {
570
+ writePrivateFile(this.#envFile, contents, this.#platform, true);
571
+ }
572
+ catch (error) {
573
+ if (isNodeError(error) && error.code === "EEXIST") {
574
+ throw new Error(`Another atlas-core init process created ${this.#envFile}. Run init again after it exits.`);
575
+ }
576
+ throw error;
577
+ }
578
+ }
579
+ #writeInitializingState(dockerEngineId, previous) {
580
+ const state = {
581
+ schema: CONFIG_SCHEMA,
582
+ phase: "initializing",
583
+ initializedAt: previous?.initializedAt ?? this.#now().toISOString(),
584
+ packageVersion: PACKAGE_VERSION,
585
+ dockerEngineId
586
+ };
587
+ try {
588
+ writePrivateFile(this.#stateFile, `${JSON.stringify(state, null, 2)}\n`, this.#platform, previous === undefined);
589
+ }
590
+ catch (error) {
591
+ if (isNodeError(error) && error.code === "EEXIST") {
592
+ throw new Error(`Another atlas-core init process created ${this.#stateFile}. Run init again after it exits.`);
593
+ }
594
+ throw error;
595
+ }
596
+ return state;
597
+ }
598
+ #writeReadyState(state) {
599
+ const readyState = {
600
+ ...state,
601
+ phase: "ready"
602
+ };
603
+ writePrivateFile(this.#stateFile, `${JSON.stringify(readyState, null, 2)}\n`, this.#platform);
604
+ }
605
+ #recordStartAttempt(state) {
606
+ const attemptedState = {
607
+ ...state,
608
+ startAttemptedAt: state.startAttemptedAt ?? this.#now().toISOString()
609
+ };
610
+ writePrivateFile(this.#stateFile, `${JSON.stringify(attemptedState, null, 2)}\n`, this.#platform);
611
+ return attemptedState;
612
+ }
613
+ #recordStarted(state) {
614
+ const startedState = {
615
+ ...state,
616
+ startAttemptedAt: state.startAttemptedAt ?? this.#now().toISOString(),
617
+ startedAt: state.startedAt ?? this.#now().toISOString()
618
+ };
619
+ writePrivateFile(this.#stateFile, `${JSON.stringify(startedState, null, 2)}\n`, this.#platform);
620
+ }
621
+ #readState() {
622
+ if (!existsSync(this.#stateFile))
623
+ return undefined;
624
+ try {
625
+ const value = JSON.parse(readFileSync(this.#stateFile, "utf8"));
626
+ if (!isDeploymentState(value))
627
+ return undefined;
628
+ return value;
629
+ }
630
+ catch {
631
+ return undefined;
632
+ }
633
+ }
634
+ #requireInitialized() {
635
+ if (!existsSync(this.#configDir) || !existsSync(this.#envFile) || !existsSync(this.#stateFile)) {
636
+ throw new Error("Atlas Core is not initialized. Run atlas-core init first.");
637
+ }
638
+ this.#assertPrivateConfiguration();
639
+ const state = this.#readState();
640
+ if (state?.schema !== CONFIG_SCHEMA || state.phase !== "ready") {
641
+ throw new Error("Atlas Core is not initialized. Run atlas-core init first.");
642
+ }
643
+ return state;
644
+ }
645
+ #prepareConfigDirectory() {
646
+ if (!existsSync(this.#configDir)) {
647
+ mkdirSync(this.#configDir, { recursive: true, mode: 0o700 });
648
+ if (this.#platform !== "win32")
649
+ chmodSync(this.#configDir, 0o700);
650
+ return;
651
+ }
652
+ this.#assertPrivateDirectory();
653
+ }
654
+ #assertPrivateConfiguration() {
655
+ this.#assertPrivateDirectory();
656
+ if (existsSync(this.#envFile))
657
+ this.#assertPrivateFile(this.#envFile);
658
+ if (existsSync(this.#stateFile))
659
+ this.#assertPrivateFile(this.#stateFile);
660
+ }
661
+ #assertPrivateDirectory() {
662
+ const info = lstatSync(this.#configDir);
663
+ if (info.isSymbolicLink() || !info.isDirectory()) {
664
+ throw new Error(`${this.#configDir} must be a regular directory, not a symlink or another file type.`);
665
+ }
666
+ this.#assertPrivateOwnershipAndMode(this.#configDir, info, 0o700);
667
+ }
668
+ #assertPrivateFile(path) {
669
+ const info = lstatSync(path);
670
+ if (info.isSymbolicLink() || !info.isFile()) {
671
+ throw new Error(`${path} must be a regular file, not a symlink or another file type.`);
672
+ }
673
+ this.#assertPrivateOwnershipAndMode(path, info, 0o600);
674
+ }
675
+ #assertPrivateOwnershipAndMode(path, info, expectedMode) {
676
+ if (this.#platform === "win32")
677
+ return;
678
+ const currentUserId = process.getuid?.();
679
+ if (currentUserId !== undefined && info.uid !== currentUserId) {
680
+ throw new Error(`${path} is owned by UID ${info.uid}, not the current user.`);
681
+ }
682
+ const actualMode = info.mode & 0o777;
683
+ if (actualMode !== expectedMode) {
684
+ throw new Error(`${path} must have mode ${expectedMode.toString(8)}, not ${actualMode.toString(8)}.`);
685
+ }
686
+ }
687
+ #assertStateMatchesRuntime(state, dockerEngineId) {
688
+ if (state.packageVersion !== PACKAGE_VERSION) {
689
+ throw new Error(`Atlas Core ${state.packageVersion} initialized this deployment, but the installed CLI is ${PACKAGE_VERSION}. ` +
690
+ `Reinstall atlas-core@${state.packageVersion}; automatic upgrades are not supported yet.`);
691
+ }
692
+ if (state.dockerEngineId !== dockerEngineId) {
693
+ throw new Error(`Atlas Core was initialized on Docker engine ${state.dockerEngineId}, but the current engine is ${dockerEngineId}. ` +
694
+ "Restore the original Docker context before operating this deployment.");
695
+ }
696
+ }
697
+ #requirePublishedImage() {
698
+ if (!this.#imageReference) {
699
+ throw new Error("This atlas-core package was not produced by the release workflow and has no pinned Core image.");
700
+ }
701
+ }
702
+ #readConfigValue(name) {
703
+ const line = readFileSync(this.#envFile, "utf8")
704
+ .split(/\r?\n/)
705
+ .find((candidate) => candidate.startsWith(`${name}=`));
706
+ const value = line?.slice(name.length + 1).trim();
707
+ if (value === undefined)
708
+ return undefined;
709
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
710
+ return value.slice(1, -1);
711
+ }
712
+ return value;
713
+ }
714
+ async #runCompose(args, inherit = false) {
715
+ return await this.#runComposeFile(this.#composeFile, args, inherit);
716
+ }
717
+ async #runInitCompose(args) {
718
+ return await this.#runComposeFile(this.#initComposeFile, args);
719
+ }
720
+ async #runComposeFile(composeFile, args, inherit = false) {
721
+ const env = { ...this.#env };
722
+ for (const variable of COMPOSE_VARIABLES)
723
+ delete env[variable];
724
+ for (const variable of Object.keys(env)) {
725
+ if (variable.startsWith("COMPOSE_"))
726
+ delete env[variable];
727
+ }
728
+ env.COMPOSE_IGNORE_ORPHANS = "0";
729
+ env.COMPOSE_REMOVE_ORPHANS = "0";
730
+ env.ATLAS_CORE_IMAGE = this.#imageReference ?? UNRELEASED_IMAGE;
731
+ return await this.#runner.run("docker", this.#composeArgs(composeFile, args), {
732
+ cwd: this.#configDir,
733
+ env,
734
+ inherit
735
+ });
736
+ }
737
+ async #runComposeChecked(args) {
738
+ const result = await this.#runCompose(args);
739
+ if (result.status !== 0)
740
+ throw commandFailure(`docker ${this.#composeArgs(this.#composeFile, args).join(" ")}`, result);
741
+ }
742
+ async #runInitComposeChecked(args) {
743
+ const result = await this.#runInitCompose(args);
744
+ if (result.status !== 0) {
745
+ throw commandFailure(`docker ${this.#composeArgs(this.#initComposeFile, args).join(" ")}`, result);
746
+ }
747
+ }
748
+ #composeArgs(composeFile, args) {
749
+ return ["compose", "--project-name", PROJECT_NAME, "--env-file", this.#envFile, "--file", composeFile, ...args];
750
+ }
751
+ }
752
+ export async function runCLI(argv, context = {}) {
753
+ const runtime = defaultContext(context);
754
+ try {
755
+ const command = parseCommand(argv);
756
+ if (command.kind === "help") {
757
+ runtime.stdout.write(usage);
758
+ return 0;
759
+ }
760
+ if (command.kind === "version") {
761
+ runtime.stdout.write(`${PACKAGE_NAME} ${PACKAGE_VERSION}\n`);
762
+ return 0;
763
+ }
764
+ const deployment = new AtlasCoreDeployment(runtime);
765
+ switch (command.kind) {
766
+ case "doctor":
767
+ return (await deployment.doctor()) ? 0 : 1;
768
+ case "init":
769
+ await deployment.init();
770
+ return 0;
771
+ case "logs":
772
+ await deployment.logs(command.service, command.follow);
773
+ return 0;
774
+ case "restart":
775
+ await deployment.restart();
776
+ return 0;
777
+ case "start":
778
+ await deployment.start();
779
+ return 0;
780
+ case "status":
781
+ return (await deployment.status()) ? 0 : 1;
782
+ case "stop":
783
+ await deployment.stop();
784
+ return 0;
785
+ default:
786
+ return assertNever(command);
787
+ }
788
+ }
789
+ catch (error) {
790
+ runtime.stderr.write(`${errorMessage(error)}\n`);
791
+ if (error instanceof UsageError)
792
+ runtime.stderr.write(usage);
793
+ return error instanceof UsageError ? 2 : 1;
794
+ }
795
+ }
796
+ function parseCommand(argv) {
797
+ if (argv.length === 0 || (argv.length === 1 && ["help", "--help", "-h"].includes(argv[0] ?? ""))) {
798
+ return { kind: "help" };
799
+ }
800
+ const [name, ...args] = argv;
801
+ switch (name) {
802
+ case "doctor":
803
+ case "init":
804
+ case "restart":
805
+ case "start":
806
+ case "status":
807
+ case "stop":
808
+ case "version":
809
+ if (args.length > 0)
810
+ throw new UsageError(`${name} does not accept arguments`);
811
+ return { kind: name };
812
+ case "logs":
813
+ return parseLogs(args);
814
+ default:
815
+ throw new UsageError(`Unknown command: ${name ?? ""}`);
816
+ }
817
+ }
818
+ function parseLogs(args) {
819
+ let service;
820
+ let follow = false;
821
+ for (const arg of args) {
822
+ if (arg === "--follow" || arg === "-f") {
823
+ follow = true;
824
+ continue;
825
+ }
826
+ if (service)
827
+ throw new UsageError("logs accepts at most one service");
828
+ if (arg === "core" || arg === "api")
829
+ service = "api";
830
+ else if (arg === "postgres" || arg === "minio")
831
+ service = arg;
832
+ else
833
+ throw new UsageError(`Unknown logs service: ${arg}`);
834
+ }
835
+ return service === undefined ? { kind: "logs", follow } : { kind: "logs", service, follow };
836
+ }
837
+ function defaultContext(context) {
838
+ const currentDirectory = dirname(fileURLToPath(import.meta.url));
839
+ return {
840
+ stdout: context.stdout ?? process.stdout,
841
+ stderr: context.stderr ?? process.stderr,
842
+ env: context.env ?? process.env,
843
+ runner: context.runner ?? new ProcessCommandRunner(),
844
+ homeDir: context.homeDir ?? homedir(),
845
+ packageRoot: context.packageRoot ?? dirname(currentDirectory),
846
+ platform: context.platform ?? process.platform,
847
+ architecture: context.architecture ?? process.arch,
848
+ nodeVersion: context.nodeVersion ?? process.versions.node,
849
+ now: context.now ?? (() => new Date()),
850
+ createSecret: context.createSecret ?? (() => randomBytes(32).toString("base64url")),
851
+ imageReference: context.imageReference ?? PACKAGE_IMAGE
852
+ };
853
+ }
854
+ function writePrivateFile(path, contents, platform, exclusive = false) {
855
+ if (exclusive) {
856
+ writeFileSync(path, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
857
+ if (platform !== "win32")
858
+ chmodSync(path, 0o600);
859
+ return;
860
+ }
861
+ const temporaryPath = `${path}.${process.pid}.tmp`;
862
+ writeFileSync(temporaryPath, contents, { encoding: "utf8", mode: 0o600, flag: "wx" });
863
+ renameSync(temporaryPath, path);
864
+ if (platform !== "win32")
865
+ chmodSync(path, 0o600);
866
+ }
867
+ function isDeploymentState(value) {
868
+ if (typeof value !== "object" || value === null)
869
+ return false;
870
+ const record = value;
871
+ return (typeof record.schema === "number" &&
872
+ (record.phase === "initializing" || record.phase === "ready") &&
873
+ typeof record.initializedAt === "string" &&
874
+ typeof record.packageVersion === "string" &&
875
+ typeof record.dockerEngineId === "string" &&
876
+ (record.startAttemptedAt === undefined || typeof record.startAttemptedAt === "string") &&
877
+ (record.startedAt === undefined || typeof record.startedAt === "string"));
878
+ }
879
+ function isDockerInfo(value) {
880
+ if (!value || typeof value !== "object")
881
+ return false;
882
+ const info = value;
883
+ return (typeof info.ID === "string" &&
884
+ info.ID.length > 0 &&
885
+ typeof info.OSType === "string" &&
886
+ typeof info.Architecture === "string");
887
+ }
888
+ function isInitLock(value) {
889
+ if (typeof value !== "object" || value === null)
890
+ return false;
891
+ const record = value;
892
+ return typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0;
893
+ }
894
+ function resolveConfigDirectory(configured, homeDir) {
895
+ if (!configured)
896
+ return join(homeDir, ".atlas", "core");
897
+ if (configured === "~")
898
+ return homeDir;
899
+ if (configured.startsWith("~/"))
900
+ return resolve(homeDir, configured.slice(2));
901
+ return resolve(configured);
902
+ }
903
+ function parseComposeServiceStates(stdout) {
904
+ const output = stdout.trim();
905
+ if (!output)
906
+ return [];
907
+ let candidates;
908
+ try {
909
+ const value = JSON.parse(output);
910
+ candidates = Array.isArray(value) ? value : [value];
911
+ }
912
+ catch {
913
+ try {
914
+ candidates = output.split(/\r?\n/).map((line) => JSON.parse(line));
915
+ }
916
+ catch {
917
+ throw new Error("Docker Compose returned invalid JSON from ps.");
918
+ }
919
+ }
920
+ return candidates.map((candidate) => {
921
+ if (typeof candidate !== "object" || candidate === null) {
922
+ throw new Error("Docker Compose returned an invalid service from ps.");
923
+ }
924
+ const record = candidate;
925
+ if (typeof record.Service !== "string" || typeof record.State !== "string" || typeof record.Health !== "string") {
926
+ throw new Error("Docker Compose returned an incomplete service from ps.");
927
+ }
928
+ return { Service: record.Service, State: record.State, Health: record.Health };
929
+ });
930
+ }
931
+ function isNodeError(error) {
932
+ return error instanceof Error && "code" in error;
933
+ }
934
+ function assertNodeVersion(version) {
935
+ const major = Number.parseInt(version.split(".")[0] ?? "", 10);
936
+ if (!Number.isInteger(major) || major < 24) {
937
+ throw new Error(`Atlas Core requires Node.js 24 or newer. Detected ${version}.`);
938
+ }
939
+ }
940
+ function assertComposeVersion(version) {
941
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
942
+ if (!match)
943
+ throw new Error(`Docker Compose returned an unsupported version: ${version}`);
944
+ const actual = match.slice(1, 4).map(Number);
945
+ for (let index = 0; index < MINIMUM_COMPOSE_VERSION.length; index += 1) {
946
+ const difference = (actual[index] ?? 0) - (MINIMUM_COMPOSE_VERSION[index] ?? 0);
947
+ if (difference > 0)
948
+ return;
949
+ if (difference < 0) {
950
+ throw new Error(`Atlas Core requires Docker Compose ${MINIMUM_COMPOSE_VERSION.join(".")} or newer. Detected ${version}.`);
951
+ }
952
+ }
953
+ }
954
+ function commandFailure(command, result) {
955
+ const detail = oneLine(result.stderr || result.stdout);
956
+ return new Error(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ""}`);
957
+ }
958
+ function oneLine(value) {
959
+ return value.trim().split(/\r?\n/, 1)[0] ?? "";
960
+ }
961
+ function errorMessage(error) {
962
+ return error instanceof Error ? error.message : String(error);
963
+ }
964
+ function assertNever(value) {
965
+ throw new Error(`Unhandled command: ${JSON.stringify(value)}`);
966
+ }
967
+ //# sourceMappingURL=application.js.map