@lore-co/cli 0.1.3 → 0.1.8

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,925 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmod, mkdir, readFile, rename, stat, writeFile, } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { AuthEmailSchema } from "@lore-co/core";
7
+ import { SELF_HOST_COMPOSE_ASSET } from "./generated-assets.js";
8
+ import { LORE_VERSION } from "./version.js";
9
+ const DEFAULT_API_PORT = 3001;
10
+ const DEFAULT_DASHBOARD_PORT = 3000;
11
+ const DEFAULT_TIMEOUT_MS = 120_000;
12
+ const MIN_TIMEOUT_MS = 1_000;
13
+ const MAX_TIMEOUT_MS = 600_000;
14
+ const PROCESS_OUTPUT_LIMIT = 2 * 1024 * 1024;
15
+ export const SELF_HOST_HELP = `lore self-host
16
+ Run a version-pinned Lore stack with Docker Compose, without cloning Lore.
17
+
18
+ Usage:
19
+ lore self-host <command> [options]
20
+
21
+ Commands:
22
+ up Create or update the stack and wait for readiness
23
+ down Stop the stack while preserving database data
24
+ status Show Docker, service, and API readiness state
25
+
26
+ Discover:
27
+ lore self-host up --help
28
+ lore self-host down --help
29
+ lore self-host status --help
30
+
31
+ Examples:
32
+ lore self-host up
33
+ lore self-host status --json
34
+ lore self-host down
35
+ `;
36
+ export const SELF_HOST_UP_HELP = `lore self-host up
37
+ Create secure persistent state, start pinned images, and wait for API readiness.
38
+
39
+ Usage:
40
+ lore self-host up [options]
41
+
42
+ Options:
43
+ --state-dir <path> State directory (default: ~/.lore/self-host)
44
+ --image-tag <semver> Pinned API/web image tag (default: CLI version)
45
+ --api-port <port> Host API port (default: 3001)
46
+ --dashboard-port <port> Host dashboard port (default: 3000)
47
+ --bind-address <address> 127.0.0.1, 0.0.0.0, or ::1 (default: 127.0.0.1)
48
+ --origin <origin> Allowed dashboard origin; repeat for multiple origins
49
+ --organization <name> Headless workspace organization (default: local)
50
+ --name <name> Headless workspace display name (default: organization)
51
+ --headless Start PostgreSQL, migrations, and API only
52
+ --dashboard Start the dashboard (default)
53
+ --timeout-ms <ms> Readiness timeout, 1000-600000 (default: 120000)
54
+ --json Print machine-readable output
55
+ --help Show this command's help
56
+
57
+ Environment:
58
+ LORE_SELF_HOST_STATE_DIR, LORE_IMAGE_TAG, API_PORT, NUXT_PORT,
59
+ API_BIND_ADDRESS, NUXT_ORIGIN, LORE_WORKSPACE_ORGANIZATION,
60
+ LORE_WORKSPACE_NAME, LORE_SELF_HOST_HEADLESS, RESEND_API_KEY,
61
+ AUTH_EMAIL_FROM
62
+
63
+ Examples:
64
+ lore self-host up
65
+ lore self-host up --headless --api-port 3101 --json
66
+ lore self-host up --image-tag 0.1.8 --origin https://lore.example.com
67
+ `;
68
+ export const SELF_HOST_DOWN_HELP = `lore self-host down
69
+ Stop the self-hosted stack. Database volumes are preserved by default.
70
+
71
+ Usage:
72
+ lore self-host down [--state-dir <path>] [--volumes --yes] [--json]
73
+
74
+ Options:
75
+ --state-dir <path> State directory (default: ~/.lore/self-host)
76
+ --volumes Also delete persistent database volumes
77
+ --yes Required with --volumes
78
+ --json Print machine-readable output
79
+ --help Show this command's help
80
+
81
+ Examples:
82
+ lore self-host down
83
+ lore self-host down --volumes --yes --json
84
+ `;
85
+ export const SELF_HOST_STATUS_HELP = `lore self-host status
86
+ Read Docker availability, Compose service state, and Lore API readiness.
87
+
88
+ Usage:
89
+ lore self-host status [--state-dir <path>] [--json]
90
+
91
+ Examples:
92
+ lore self-host status
93
+ lore self-host status --json
94
+ `;
95
+ function environmentValue(environment, name) {
96
+ const value = environment[name]?.trim();
97
+ return value === undefined || value === "" ? undefined : value;
98
+ }
99
+ function expandHome(path, home) {
100
+ if (path === "~") {
101
+ return home;
102
+ }
103
+ return path.startsWith("~/") ? resolve(home, path.slice(2)) : resolve(path);
104
+ }
105
+ function defaultStateDirectory(environment) {
106
+ const home = resolve(environment.HOME ?? homedir());
107
+ return expandHome(environmentValue(environment, "LORE_SELF_HOST_STATE_DIR") ??
108
+ resolve(home, ".lore", "self-host"), home);
109
+ }
110
+ function statePaths(directory) {
111
+ return {
112
+ directory,
113
+ compose: resolve(directory, "compose.yml"),
114
+ environment: resolve(directory, "lore.env"),
115
+ metadata: resolve(directory, "state.json"),
116
+ };
117
+ }
118
+ function valueAfter(args, index, flag) {
119
+ const value = args[index + 1];
120
+ if (value === undefined || value.startsWith("--")) {
121
+ throw new Error(`Missing value for ${flag}`);
122
+ }
123
+ return [value, index + 1];
124
+ }
125
+ function parseInteger(value, flag) {
126
+ const parsed = Number(value);
127
+ if (!Number.isInteger(parsed)) {
128
+ throw new Error(`${flag} requires an integer`);
129
+ }
130
+ return parsed;
131
+ }
132
+ function parsePort(value, flag) {
133
+ const port = parseInteger(value, flag);
134
+ if (port < 1 || port > 65_535) {
135
+ throw new Error(`${flag} must be between 1 and 65535`);
136
+ }
137
+ return port;
138
+ }
139
+ function parseBooleanEnvironment(value, name) {
140
+ if (value === undefined) {
141
+ return undefined;
142
+ }
143
+ if (value === "1" || value === "true") {
144
+ return true;
145
+ }
146
+ if (value === "0" || value === "false") {
147
+ return false;
148
+ }
149
+ throw new Error(`${name} must be true, false, 1, or 0`);
150
+ }
151
+ function commonDefaults(environment) {
152
+ return {
153
+ stateDirectory: defaultStateDirectory(environment),
154
+ json: false,
155
+ };
156
+ }
157
+ function parseUpArguments(args, environment) {
158
+ const parsed = {
159
+ ...commonDefaults(environment),
160
+ origins: [],
161
+ timeoutMs: DEFAULT_TIMEOUT_MS,
162
+ };
163
+ const seen = new Set();
164
+ for (let index = 0; index < args.length; index += 1) {
165
+ const flag = args[index];
166
+ if (flag === "--help" || flag === "-h") {
167
+ process.stdout.write(SELF_HOST_UP_HELP);
168
+ return null;
169
+ }
170
+ if (flag === "--json") {
171
+ parsed.json = true;
172
+ continue;
173
+ }
174
+ if (flag === "--headless" || flag === "--dashboard") {
175
+ if (seen.has("--mode")) {
176
+ throw new Error("Use only one of --headless or --dashboard");
177
+ }
178
+ seen.add("--mode");
179
+ parsed.headless = flag === "--headless";
180
+ continue;
181
+ }
182
+ if (flag !== "--state-dir" &&
183
+ flag !== "--image-tag" &&
184
+ flag !== "--api-port" &&
185
+ flag !== "--dashboard-port" &&
186
+ flag !== "--bind-address" &&
187
+ flag !== "--origin" &&
188
+ flag !== "--organization" &&
189
+ flag !== "--name" &&
190
+ flag !== "--timeout-ms") {
191
+ throw new Error(`Unknown self-host up option: ${flag ?? ""}\nTry: lore self-host up --help`);
192
+ }
193
+ if (flag !== "--origin" && seen.has(flag)) {
194
+ throw new Error(`Self-host up option may be provided once: ${flag}`);
195
+ }
196
+ seen.add(flag);
197
+ const [value, valueIndex] = valueAfter(args, index, flag);
198
+ index = valueIndex;
199
+ if (flag === "--state-dir") {
200
+ parsed.stateDirectory = expandHome(value, resolve(environment.HOME ?? homedir()));
201
+ }
202
+ else if (flag === "--image-tag") {
203
+ parsed.imageTag = value;
204
+ }
205
+ else if (flag === "--api-port") {
206
+ parsed.apiPort = parsePort(value, flag);
207
+ }
208
+ else if (flag === "--dashboard-port") {
209
+ parsed.dashboardPort = parsePort(value, flag);
210
+ }
211
+ else if (flag === "--bind-address") {
212
+ parsed.bindAddress = value;
213
+ }
214
+ else if (flag === "--origin") {
215
+ parsed.origins.push(value);
216
+ }
217
+ else if (flag === "--organization") {
218
+ parsed.organization = value;
219
+ }
220
+ else if (flag === "--name") {
221
+ parsed.workspaceName = value;
222
+ }
223
+ else {
224
+ parsed.timeoutMs = parseInteger(value, flag);
225
+ if (parsed.timeoutMs < MIN_TIMEOUT_MS ||
226
+ parsed.timeoutMs > MAX_TIMEOUT_MS) {
227
+ throw new Error("--timeout-ms must be between 1000 and 600000");
228
+ }
229
+ }
230
+ }
231
+ return parsed;
232
+ }
233
+ function parseCommonOutputArguments(args, environment, help, command) {
234
+ const parsed = commonDefaults(environment);
235
+ for (let index = 0; index < args.length; index += 1) {
236
+ const flag = args[index];
237
+ if (flag === "--help" || flag === "-h") {
238
+ process.stdout.write(help);
239
+ return null;
240
+ }
241
+ if (flag === "--json") {
242
+ parsed.json = true;
243
+ continue;
244
+ }
245
+ if (flag === "--state-dir") {
246
+ const [value, valueIndex] = valueAfter(args, index, flag);
247
+ parsed.stateDirectory = expandHome(value, resolve(environment.HOME ?? homedir()));
248
+ index = valueIndex;
249
+ continue;
250
+ }
251
+ throw new Error(`Unknown self-host ${command} option: ${flag ?? ""}\nTry: lore self-host ${command} --help`);
252
+ }
253
+ return parsed;
254
+ }
255
+ function parseDownArguments(args, environment) {
256
+ const parsed = {
257
+ ...commonDefaults(environment),
258
+ volumes: false,
259
+ yes: false,
260
+ };
261
+ for (let index = 0; index < args.length; index += 1) {
262
+ const flag = args[index];
263
+ if (flag === "--help" || flag === "-h") {
264
+ process.stdout.write(SELF_HOST_DOWN_HELP);
265
+ return null;
266
+ }
267
+ if (flag === "--json") {
268
+ parsed.json = true;
269
+ }
270
+ else if (flag === "--volumes") {
271
+ parsed.volumes = true;
272
+ }
273
+ else if (flag === "--yes") {
274
+ parsed.yes = true;
275
+ }
276
+ else if (flag === "--state-dir") {
277
+ const [value, valueIndex] = valueAfter(args, index, flag);
278
+ parsed.stateDirectory = expandHome(value, resolve(environment.HOME ?? homedir()));
279
+ index = valueIndex;
280
+ }
281
+ else {
282
+ throw new Error(`Unknown self-host down option: ${flag ?? ""}\nTry: lore self-host down --help`);
283
+ }
284
+ }
285
+ if (parsed.volumes && !parsed.yes) {
286
+ throw new Error("Deleting database volumes requires --volumes --yes.\nExample: lore self-host down --volumes --yes");
287
+ }
288
+ if (parsed.yes && !parsed.volumes) {
289
+ throw new Error("--yes is valid only with --volumes");
290
+ }
291
+ return parsed;
292
+ }
293
+ function normalizeImageTag(value) {
294
+ const tag = value.trim().replace(/^v(?=\d)/u, "");
295
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$/u.test(tag)) {
296
+ throw new Error("--image-tag must be a pinned semantic version such as 0.1.8");
297
+ }
298
+ return tag;
299
+ }
300
+ function normalizeBindAddress(value) {
301
+ const address = value.trim();
302
+ if (address !== "127.0.0.1" &&
303
+ address !== "0.0.0.0" &&
304
+ address !== "::1" &&
305
+ address !== "[::1]") {
306
+ throw new Error("--bind-address must be 127.0.0.1, 0.0.0.0, or ::1");
307
+ }
308
+ return address === "::1" ? "[::1]" : address;
309
+ }
310
+ function normalizeOrigin(value) {
311
+ let origin;
312
+ try {
313
+ origin = new URL(value);
314
+ }
315
+ catch {
316
+ throw new Error(`Invalid self-host origin: ${value}`);
317
+ }
318
+ if ((origin.protocol !== "http:" && origin.protocol !== "https:") ||
319
+ origin.username !== "" ||
320
+ origin.password !== "" ||
321
+ origin.pathname !== "/" ||
322
+ origin.search !== "" ||
323
+ origin.hash !== "") {
324
+ throw new Error("Self-host origins must be HTTP(S) origins without credentials or paths");
325
+ }
326
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(origin.hostname);
327
+ if (origin.protocol !== "https:" && !loopback) {
328
+ throw new Error("Self-host origins must use HTTPS unless they are loopback origins");
329
+ }
330
+ return origin.origin;
331
+ }
332
+ function boundedName(value, flag) {
333
+ const normalized = value.trim();
334
+ if (normalized.length < 1 ||
335
+ normalized.length > 200 ||
336
+ /[\r\n\0]/u.test(normalized)) {
337
+ throw new Error(`${flag} must contain 1-200 characters on one line`);
338
+ }
339
+ return normalized;
340
+ }
341
+ function projectName(directory) {
342
+ return `lore-${createHash("sha256").update(directory).digest("hex").slice(0, 10)}`;
343
+ }
344
+ function createSecret() {
345
+ return randomBytes(32).toString("base64url");
346
+ }
347
+ function quoteEnvironmentValue(value) {
348
+ if (/[\r\n\0]/u.test(value)) {
349
+ throw new Error("Self-host environment values must fit on one line");
350
+ }
351
+ return JSON.stringify(value.replaceAll("$", "$$"));
352
+ }
353
+ function serializeEnvironment(values) {
354
+ return `${Object.entries(values)
355
+ .map(([name, value]) => `${name}=${quoteEnvironmentValue(value)}`)
356
+ .join("\n")}\n`;
357
+ }
358
+ function parseEnvironmentFile(raw) {
359
+ const values = {};
360
+ for (const line of raw.split(/\r?\n/u)) {
361
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
362
+ continue;
363
+ }
364
+ const separator = line.indexOf("=");
365
+ if (separator < 1) {
366
+ throw new Error("Self-host environment file is invalid");
367
+ }
368
+ const name = line.slice(0, separator);
369
+ const encoded = line.slice(separator + 1);
370
+ if (!/^[A-Z][A-Z0-9_]*$/u.test(name)) {
371
+ throw new Error("Self-host environment file is invalid");
372
+ }
373
+ try {
374
+ const parsed = JSON.parse(encoded);
375
+ if (typeof parsed !== "string") {
376
+ throw new Error("not a string");
377
+ }
378
+ values[name] = parsed.replaceAll("$$", "$");
379
+ }
380
+ catch {
381
+ throw new Error("Self-host environment file is invalid");
382
+ }
383
+ }
384
+ return values;
385
+ }
386
+ async function optionalFile(path) {
387
+ try {
388
+ return await readFile(path, "utf8");
389
+ }
390
+ catch (error) {
391
+ const code = typeof error === "object" && error !== null && "code" in error
392
+ ? error.code
393
+ : undefined;
394
+ if (code === "ENOENT") {
395
+ return null;
396
+ }
397
+ throw error;
398
+ }
399
+ }
400
+ async function atomicWrite(path, content, mode = 0o600) {
401
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
402
+ await writeFile(temporary, content, {
403
+ encoding: "utf8",
404
+ mode,
405
+ flag: "wx",
406
+ });
407
+ await rename(temporary, path);
408
+ await chmod(path, mode);
409
+ }
410
+ function parseState(raw) {
411
+ let value;
412
+ try {
413
+ value = JSON.parse(raw);
414
+ }
415
+ catch {
416
+ throw new Error("Self-host state metadata is invalid");
417
+ }
418
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
419
+ throw new Error("Self-host state metadata is invalid");
420
+ }
421
+ const record = value;
422
+ if (record.version !== 1 ||
423
+ typeof record.projectName !== "string" ||
424
+ typeof record.imageTag !== "string" ||
425
+ typeof record.apiPort !== "number" ||
426
+ typeof record.dashboardPort !== "number" ||
427
+ typeof record.bindAddress !== "string" ||
428
+ !Array.isArray(record.origins) ||
429
+ !record.origins.every((origin) => typeof origin === "string") ||
430
+ typeof record.organization !== "string" ||
431
+ typeof record.workspaceName !== "string" ||
432
+ typeof record.headless !== "boolean" ||
433
+ typeof record.createdAt !== "string") {
434
+ throw new Error("Self-host state metadata is invalid");
435
+ }
436
+ return record;
437
+ }
438
+ async function readState(paths) {
439
+ const raw = await optionalFile(paths.metadata);
440
+ return raw === null ? null : parseState(raw);
441
+ }
442
+ function processResult(command, args, timeoutMs) {
443
+ return new Promise((resolvePromise, reject) => {
444
+ const child = spawn(command, [...args], {
445
+ stdio: ["ignore", "pipe", "pipe"],
446
+ env: process.env,
447
+ });
448
+ let stdout = "";
449
+ let stderr = "";
450
+ let timedOut = false;
451
+ const timeout = setTimeout(() => {
452
+ timedOut = true;
453
+ child.kill("SIGTERM");
454
+ }, timeoutMs);
455
+ child.stdout.setEncoding("utf8");
456
+ child.stderr.setEncoding("utf8");
457
+ child.stdout.on("data", (chunk) => {
458
+ stdout += chunk;
459
+ if (stdout.length > PROCESS_OUTPUT_LIMIT) {
460
+ child.kill("SIGTERM");
461
+ }
462
+ });
463
+ child.stderr.on("data", (chunk) => {
464
+ stderr += chunk;
465
+ if (stderr.length > PROCESS_OUTPUT_LIMIT) {
466
+ child.kill("SIGTERM");
467
+ }
468
+ });
469
+ child.once("error", (error) => {
470
+ clearTimeout(timeout);
471
+ reject(error);
472
+ });
473
+ child.once("close", (code) => {
474
+ clearTimeout(timeout);
475
+ if (timedOut) {
476
+ resolvePromise({ code: 124, stdout, stderr });
477
+ return;
478
+ }
479
+ resolvePromise({ code: code ?? 1, stdout, stderr });
480
+ });
481
+ });
482
+ }
483
+ async function docker(args, timeoutMs = 30_000) {
484
+ try {
485
+ return await processResult("docker", args, timeoutMs);
486
+ }
487
+ catch (error) {
488
+ const code = typeof error === "object" && error !== null && "code" in error
489
+ ? error.code
490
+ : undefined;
491
+ if (code === "ENOENT") {
492
+ return { code: 127, stdout: "", stderr: "" };
493
+ }
494
+ throw error;
495
+ }
496
+ }
497
+ async function requireDocker() {
498
+ const compose = await docker(["compose", "version"], 15_000);
499
+ if (compose.code !== 0) {
500
+ throw new Error("Docker Compose is unavailable. Install or start Docker Desktop, then retry.");
501
+ }
502
+ const daemon = await docker(["info"], 15_000);
503
+ if (daemon.code !== 0) {
504
+ throw new Error("The Docker daemon is unavailable. Start Docker Desktop, then retry.");
505
+ }
506
+ }
507
+ function composeArguments(paths, state) {
508
+ return [
509
+ "compose",
510
+ "--project-name",
511
+ state.projectName,
512
+ "--env-file",
513
+ paths.environment,
514
+ "-f",
515
+ paths.compose,
516
+ ];
517
+ }
518
+ async function waitForReadiness(apiPort, timeoutMs) {
519
+ const deadline = Date.now() + timeoutMs;
520
+ const url = `http://127.0.0.1:${apiPort}/health/ready`;
521
+ while (Date.now() < deadline) {
522
+ try {
523
+ const response = await fetch(url, {
524
+ signal: AbortSignal.timeout(Math.min(2_000, timeoutMs)),
525
+ });
526
+ if (response.ok) {
527
+ return;
528
+ }
529
+ }
530
+ catch {
531
+ // Startup connection failures are retried until the bounded deadline.
532
+ }
533
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 500));
534
+ }
535
+ throw new Error("Lore did not become ready before the timeout. Run lore self-host status for details.");
536
+ }
537
+ function writeResult(value, json, text) {
538
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text);
539
+ }
540
+ function resolvedUpState(args, existing, environment) {
541
+ const imageTag = normalizeImageTag(args.imageTag ??
542
+ environmentValue(environment, "LORE_IMAGE_TAG") ??
543
+ existing?.imageTag ??
544
+ LORE_VERSION);
545
+ const apiPort = args.apiPort ??
546
+ (environmentValue(environment, "API_PORT") === undefined
547
+ ? undefined
548
+ : parsePort(environment.API_PORT, "API_PORT")) ??
549
+ existing?.apiPort ??
550
+ DEFAULT_API_PORT;
551
+ const dashboardPort = args.dashboardPort ??
552
+ (environmentValue(environment, "NUXT_PORT") === undefined
553
+ ? undefined
554
+ : parsePort(environment.NUXT_PORT, "NUXT_PORT")) ??
555
+ existing?.dashboardPort ??
556
+ DEFAULT_DASHBOARD_PORT;
557
+ const bindAddress = normalizeBindAddress(args.bindAddress ??
558
+ environmentValue(environment, "API_BIND_ADDRESS") ??
559
+ existing?.bindAddress ??
560
+ "127.0.0.1");
561
+ const environmentOrigins = environmentValue(environment, "NUXT_ORIGIN")
562
+ ?.split(",")
563
+ .map((origin) => origin.trim())
564
+ .filter(Boolean) ?? [];
565
+ const persistedOrigins = existing !== null &&
566
+ dashboardPort !== existing.dashboardPort &&
567
+ existing.origins.length === 1 &&
568
+ (existing.origins[0] ===
569
+ `http://localhost:${existing.dashboardPort}` ||
570
+ existing.origins[0] ===
571
+ `http://127.0.0.1:${existing.dashboardPort}`)
572
+ ? [`http://127.0.0.1:${dashboardPort}`]
573
+ : existing?.origins;
574
+ const origins = [
575
+ ...new Set((args.origins.length > 0
576
+ ? args.origins
577
+ : environmentOrigins.length > 0
578
+ ? environmentOrigins
579
+ : persistedOrigins ?? [`http://127.0.0.1:${dashboardPort}`]).map(normalizeOrigin)),
580
+ ];
581
+ const environmentHeadless = parseBooleanEnvironment(environmentValue(environment, "LORE_SELF_HOST_HEADLESS"), "LORE_SELF_HOST_HEADLESS");
582
+ const headless = args.headless ?? environmentHeadless ?? existing?.headless ?? false;
583
+ if (existing !== null && headless !== existing.headless) {
584
+ throw new Error("Self-host mode cannot change after initialization. Use a new --state-dir.");
585
+ }
586
+ const organization = boundedName(args.organization ??
587
+ environmentValue(environment, "LORE_WORKSPACE_ORGANIZATION") ??
588
+ existing?.organization ??
589
+ "local", "--organization");
590
+ const workspaceName = boundedName(args.workspaceName ??
591
+ environmentValue(environment, "LORE_WORKSPACE_NAME") ??
592
+ existing?.workspaceName ??
593
+ organization, "--name");
594
+ if (existing !== null &&
595
+ (organization !== existing.organization ||
596
+ workspaceName !== existing.workspaceName)) {
597
+ throw new Error("Workspace organization and name cannot change after initialization. Use a new --state-dir.");
598
+ }
599
+ return {
600
+ version: 1,
601
+ projectName: existing?.projectName ?? projectName(args.stateDirectory),
602
+ imageTag,
603
+ apiPort,
604
+ dashboardPort,
605
+ bindAddress,
606
+ origins,
607
+ organization,
608
+ workspaceName,
609
+ headless,
610
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
611
+ };
612
+ }
613
+ function environmentForState(state, secrets, environment, existing) {
614
+ const apiUrl = `http://127.0.0.1:${state.apiPort}`;
615
+ const primaryOrigin = state.origins[0] ?? `http://127.0.0.1:${state.dashboardPort}`;
616
+ const localDelivery = state.origins.every((origin) => ["localhost", "127.0.0.1", "::1"].includes(new URL(origin).hostname));
617
+ const resendApiKey = environmentValue(environment, "RESEND_API_KEY") ??
618
+ existing.RESEND_API_KEY?.trim();
619
+ const authEmailFrom = environmentValue(environment, "AUTH_EMAIL_FROM") ??
620
+ existing.AUTH_EMAIL_FROM?.trim();
621
+ const legacyDashboardBootstrap = !state.headless && secrets.workspaceToken !== undefined;
622
+ const configuredOwnerEmail = legacyDashboardBootstrap
623
+ ? (environmentValue(environment, "AUTH_OWNER_EMAIL") ??
624
+ existing.AUTH_OWNER_EMAIL?.trim())
625
+ : undefined;
626
+ const ownerEmail = configuredOwnerEmail === undefined
627
+ ? undefined
628
+ : AuthEmailSchema.safeParse(configuredOwnerEmail);
629
+ if (ownerEmail !== undefined && !ownerEmail.success) {
630
+ throw new Error("AUTH_OWNER_EMAIL must be a valid email address.");
631
+ }
632
+ if (!state.headless &&
633
+ !localDelivery &&
634
+ (resendApiKey === undefined ||
635
+ resendApiKey === "" ||
636
+ authEmailFrom === undefined ||
637
+ authEmailFrom === "")) {
638
+ throw new Error("Public self-host origins require RESEND_API_KEY and AUTH_EMAIL_FROM for magic-link delivery.");
639
+ }
640
+ if (legacyDashboardBootstrap &&
641
+ !localDelivery &&
642
+ ownerEmail === undefined) {
643
+ throw new Error("Existing public workspace bootstraps require AUTH_OWNER_EMAIL until migrated.");
644
+ }
645
+ const authEnvironment = state.headless
646
+ ? {
647
+ AUTH_MODE: "disabled",
648
+ AUTH_EMAIL_MODE: "disabled",
649
+ }
650
+ : localDelivery
651
+ ? {
652
+ AUTH_MODE: "magic_link",
653
+ AUTH_EMAIL_MODE: "local",
654
+ }
655
+ : {
656
+ AUTH_MODE: "magic_link",
657
+ AUTH_EMAIL_MODE: "resend",
658
+ RESEND_API_KEY: resendApiKey,
659
+ AUTH_EMAIL_FROM: authEmailFrom,
660
+ };
661
+ const bootstrapEnvironment = secrets.workspaceToken === undefined
662
+ ? {}
663
+ : {
664
+ LORE_WORKSPACE_TOKEN: secrets.workspaceToken,
665
+ LORE_WORKSPACE_ORGANIZATION: state.organization,
666
+ LORE_WORKSPACE_NAME: state.workspaceName,
667
+ ...(ownerEmail === undefined
668
+ ? {}
669
+ : { AUTH_OWNER_EMAIL: ownerEmail.data }),
670
+ };
671
+ return {
672
+ LORE_IMAGE_TAG: state.imageTag,
673
+ LORE_SERVER_VERSION: state.imageTag,
674
+ POSTGRES_PASSWORD: secrets.postgresPassword,
675
+ RATE_LIMIT_KEY_SECRET: secrets.rateLimitKeySecret,
676
+ API_BIND_ADDRESS: state.bindAddress,
677
+ API_PORT: String(state.apiPort),
678
+ NUXT_BIND_ADDRESS: state.bindAddress,
679
+ NUXT_PORT: String(state.dashboardPort),
680
+ NUXT_ORIGIN: state.origins.join(","),
681
+ NUXT_PUBLIC_LORE_CONNECTOR_API_URL: apiUrl,
682
+ NUXT_AUTH_COOKIE_SECURE: String(new URL(primaryOrigin).protocol === "https:"),
683
+ ...authEnvironment,
684
+ AUTH_WEB_ORIGIN: primaryOrigin,
685
+ ...bootstrapEnvironment,
686
+ };
687
+ }
688
+ function readSecrets(values, requireWorkspaceToken) {
689
+ const postgresPassword = values.POSTGRES_PASSWORD;
690
+ const rateLimitKeySecret = values.RATE_LIMIT_KEY_SECRET?.trim() || createSecret();
691
+ const workspaceToken = values.LORE_WORKSPACE_TOKEN?.trim() || undefined;
692
+ if (postgresPassword === undefined ||
693
+ postgresPassword.length < 43 ||
694
+ rateLimitKeySecret.length < 32 ||
695
+ rateLimitKeySecret === postgresPassword ||
696
+ rateLimitKeySecret === workspaceToken ||
697
+ (workspaceToken !== undefined &&
698
+ !/^lore_[A-Za-z0-9_-]{43}$/u.test(workspaceToken)) ||
699
+ (requireWorkspaceToken && workspaceToken === undefined) ||
700
+ (workspaceToken !== undefined && postgresPassword === workspaceToken)) {
701
+ throw new Error("Self-host secrets are missing or invalid. Refusing to replace existing state.");
702
+ }
703
+ return {
704
+ postgresPassword,
705
+ rateLimitKeySecret,
706
+ ...(workspaceToken === undefined ? {} : { workspaceToken }),
707
+ };
708
+ }
709
+ async function upCommand(args, environment) {
710
+ const parsed = parseUpArguments(args, environment);
711
+ if (parsed === null) {
712
+ return;
713
+ }
714
+ const paths = statePaths(parsed.stateDirectory);
715
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
716
+ await chmod(paths.directory, 0o700);
717
+ const [existingState, existingEnvironment] = await Promise.all([
718
+ readState(paths),
719
+ optionalFile(paths.environment),
720
+ ]);
721
+ if ((existingState === null) !== (existingEnvironment === null)) {
722
+ throw new Error("Self-host state is incomplete. Restore both lore.env and state.json or use a new --state-dir.");
723
+ }
724
+ const state = resolvedUpState(parsed, existingState, environment);
725
+ const existingValues = existingEnvironment === null
726
+ ? {}
727
+ : parseEnvironmentFile(existingEnvironment);
728
+ const secrets = existingEnvironment === null
729
+ ? {
730
+ postgresPassword: createSecret(),
731
+ rateLimitKeySecret: createSecret(),
732
+ ...(state.headless
733
+ ? { workspaceToken: `lore_${createSecret()}` }
734
+ : {}),
735
+ }
736
+ : readSecrets(existingValues, state.headless);
737
+ const stateEnvironment = environmentForState(state, secrets, environment, existingValues);
738
+ await Promise.all([
739
+ atomicWrite(paths.environment, serializeEnvironment(stateEnvironment)),
740
+ atomicWrite(paths.compose, SELF_HOST_COMPOSE_ASSET),
741
+ atomicWrite(paths.metadata, `${JSON.stringify(state, null, 2)}\n`),
742
+ ]);
743
+ await requireDocker();
744
+ const waitSeconds = Math.max(1, Math.ceil(parsed.timeoutMs / 1_000));
745
+ const upArguments = [
746
+ ...composeArguments(paths, state),
747
+ "up",
748
+ "-d",
749
+ "--wait",
750
+ "--wait-timeout",
751
+ String(waitSeconds),
752
+ "--remove-orphans",
753
+ ...(state.headless ? ["postgres", "api"] : []),
754
+ ];
755
+ const started = await docker(upArguments, parsed.timeoutMs + 30_000);
756
+ if (started.code !== 0) {
757
+ throw new Error(`Docker Compose could not start Lore. Run lore self-host status --state-dir ${paths.directory} for details.`);
758
+ }
759
+ await waitForReadiness(state.apiPort, parsed.timeoutMs);
760
+ const apiUrl = `http://127.0.0.1:${state.apiPort}`;
761
+ const dashboardUrl = state.headless
762
+ ? null
763
+ : (state.origins[0] ?? `http://127.0.0.1:${state.dashboardPort}`);
764
+ const signupUrl = dashboardUrl === null ? null : new URL("/signup", dashboardUrl).toString();
765
+ const loginUrl = dashboardUrl === null ? null : new URL("/login", dashboardUrl).toString();
766
+ const localMagicLinks = dashboardUrl !== null && stateEnvironment.AUTH_EMAIL_MODE === "local";
767
+ const result = {
768
+ status: "ready",
769
+ stateDirectory: paths.directory,
770
+ imageTag: state.imageTag,
771
+ headless: state.headless,
772
+ apiUrl,
773
+ dashboardUrl,
774
+ signupUrl,
775
+ loginUrl,
776
+ magicLinkDelivery: dashboardUrl === null
777
+ ? null
778
+ : localMagicLinks
779
+ ? "container_logs"
780
+ : "resend",
781
+ };
782
+ writeResult(result, parsed.json, `status: ready\napi_url: ${apiUrl}\n${dashboardUrl === null ? "" : `dashboard_url: ${dashboardUrl}\n`}${signupUrl === null ? "" : `signup_url: ${signupUrl}\n`}${loginUrl === null ? "" : `login_url: ${loginUrl}\n`}${dashboardUrl === null
783
+ ? ""
784
+ : localMagicLinks
785
+ ? `magic_link_logs: docker compose --project-name ${state.projectName} --file ${paths.compose} --env-file ${paths.environment} logs api\n`
786
+ : "magic_link_delivery: resend\n"}state_directory: ${paths.directory}\n`);
787
+ }
788
+ async function downCommand(args, environment) {
789
+ const parsed = parseDownArguments(args, environment);
790
+ if (parsed === null) {
791
+ return;
792
+ }
793
+ const paths = statePaths(parsed.stateDirectory);
794
+ const state = await readState(paths);
795
+ if (state === null) {
796
+ writeResult({
797
+ status: "not_initialized",
798
+ stateDirectory: paths.directory,
799
+ volumesDeleted: false,
800
+ }, parsed.json, `status: not_initialized\nstate_directory: ${paths.directory}\n`);
801
+ return;
802
+ }
803
+ await requireDocker();
804
+ const stopped = await docker([
805
+ ...composeArguments(paths, state),
806
+ "down",
807
+ "--remove-orphans",
808
+ ...(parsed.volumes ? ["--volumes"] : []),
809
+ ]);
810
+ if (stopped.code !== 0) {
811
+ throw new Error("Docker Compose could not stop Lore. Run lore self-host status for details.");
812
+ }
813
+ const result = {
814
+ status: "stopped",
815
+ stateDirectory: paths.directory,
816
+ volumesDeleted: parsed.volumes,
817
+ };
818
+ writeResult(result, parsed.json, `status: stopped\ndatabase_data: ${parsed.volumes ? "deleted" : "preserved"}\nstate_directory: ${paths.directory}\n`);
819
+ }
820
+ function parseComposeServices(output) {
821
+ const trimmed = output.trim();
822
+ if (trimmed === "") {
823
+ return [];
824
+ }
825
+ try {
826
+ const parsed = JSON.parse(trimmed);
827
+ return Array.isArray(parsed) ? parsed : [parsed];
828
+ }
829
+ catch {
830
+ const services = [];
831
+ for (const line of trimmed.split(/\r?\n/u)) {
832
+ try {
833
+ services.push(JSON.parse(line));
834
+ }
835
+ catch {
836
+ return [{ raw: trimmed }];
837
+ }
838
+ }
839
+ return services;
840
+ }
841
+ }
842
+ async function readinessState(state) {
843
+ try {
844
+ const response = await fetch(`http://127.0.0.1:${state.apiPort}/health/ready`, { signal: AbortSignal.timeout(3_000) });
845
+ return {
846
+ state: response.ok ? "ready" : "unready",
847
+ status: response.status,
848
+ };
849
+ }
850
+ catch {
851
+ return { state: "unreachable", status: null };
852
+ }
853
+ }
854
+ async function statusCommand(args, environment) {
855
+ const parsed = parseCommonOutputArguments(args, environment, SELF_HOST_STATUS_HELP, "status");
856
+ if (parsed === null) {
857
+ return;
858
+ }
859
+ const paths = statePaths(parsed.stateDirectory);
860
+ const state = await readState(paths);
861
+ const version = await docker(["--version"], 5_000);
862
+ const dockerInstalled = version.code === 0;
863
+ const compose = dockerInstalled
864
+ ? await docker(["compose", "version"], 5_000)
865
+ : { code: 127, stdout: "", stderr: "" };
866
+ const daemon = compose.code === 0
867
+ ? await docker(["info"], 5_000)
868
+ : { code: 127, stdout: "", stderr: "" };
869
+ let services = [];
870
+ if (state !== null && daemon.code === 0) {
871
+ const serviceResult = await docker([
872
+ ...composeArguments(paths, state),
873
+ "ps",
874
+ "--format",
875
+ "json",
876
+ ]);
877
+ if (serviceResult.code === 0) {
878
+ services = parseComposeServices(serviceResult.stdout);
879
+ }
880
+ }
881
+ const health = state === null
882
+ ? { state: "unreachable", status: null }
883
+ : await readinessState(state);
884
+ const result = {
885
+ initialized: state !== null,
886
+ stateDirectory: paths.directory,
887
+ docker: {
888
+ installed: dockerInstalled,
889
+ compose: compose.code === 0,
890
+ daemon: daemon.code === 0,
891
+ },
892
+ stack: state === null
893
+ ? null
894
+ : {
895
+ projectName: state.projectName,
896
+ imageTag: state.imageTag,
897
+ headless: state.headless,
898
+ services,
899
+ },
900
+ health,
901
+ };
902
+ writeResult(result, parsed.json, `initialized: ${result.initialized ? "yes" : "no"}\ndocker: ${dockerInstalled ? "installed" : "missing"}\ncompose: ${compose.code === 0 ? "available" : "unavailable"}\ndaemon: ${daemon.code === 0 ? "available" : "unavailable"}\napi_health: ${health.state}\nstate_directory: ${paths.directory}\n`);
903
+ }
904
+ export async function runSelfHostCommand(args, environment = process.env) {
905
+ const command = args[0];
906
+ if (command === undefined || command === "--help" || command === "-h") {
907
+ process.stdout.write(SELF_HOST_HELP);
908
+ return;
909
+ }
910
+ const commandArgs = args.slice(1);
911
+ switch (command) {
912
+ case "up":
913
+ await upCommand(commandArgs, environment);
914
+ return;
915
+ case "down":
916
+ await downCommand(commandArgs, environment);
917
+ return;
918
+ case "status":
919
+ await statusCommand(commandArgs, environment);
920
+ return;
921
+ default:
922
+ throw new Error(`Unknown self-host command: ${command}\nTry: lore self-host --help`);
923
+ }
924
+ }
925
+ //# sourceMappingURL=self-host.js.map