@lore-co/cli 0.1.2 → 0.1.4

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