@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.
- package/README.md +72 -24
- package/dist/cli.d.ts +12 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +300 -78
- package/dist/cli.js.map +1 -1
- package/dist/demo.d.ts +24 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/demo.js +402 -0
- package/dist/demo.js.map +1 -0
- package/dist/generated-assets.d.ts +4 -3
- package/dist/generated-assets.d.ts.map +1 -1
- package/dist/generated-assets.js +4 -3
- package/dist/generated-assets.js.map +1 -1
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +2 -4
- package/dist/github.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/repository.d.ts +1 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +6 -0
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts +15 -7
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +271 -44
- package/dist/runtime.js.map +1 -1
- package/dist/self-host.d.ts +7 -0
- package/dist/self-host.d.ts.map +1 -0
- package/dist/self-host.js +981 -0
- package/dist/self-host.js.map +1 -0
- package/dist/update.js +3 -3
- package/package.json +5 -4
|
@@ -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
|