nylorun 0.2.1-beta → 0.4.0-beta
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/CHANGELOG.md +126 -0
- package/README.md +156 -46
- package/dist/cli.js +13 -11
- package/dist/doctor.d.ts +3 -1
- package/dist/doctor.js +42 -13
- package/dist/project/derived-key.d.ts +8 -0
- package/dist/project/derived-key.js +18 -0
- package/dist/project/link.d.ts +32 -4
- package/dist/project/link.js +83 -14
- package/dist/project/seed.d.ts +27 -0
- package/dist/project/seed.js +111 -0
- package/dist/stack/commands.d.ts +45 -15
- package/dist/stack/commands.js +577 -125
- package/dist/stack/compose-file.d.ts +30 -8
- package/dist/stack/compose-file.js +99 -15
- package/dist/stack/env-file.d.ts +32 -4
- package/dist/stack/env-file.js +71 -3
- package/dist/stack/host-files.d.ts +16 -3
- package/dist/stack/host-files.js +29 -4
- package/dist/stack/images.d.ts +2 -0
- package/dist/stack/images.js +10 -6
- package/dist/stack/index.d.ts +3 -3
- package/dist/stack/index.js +8 -3
- package/dist/stack/paths.d.ts +11 -3
- package/dist/stack/paths.js +9 -6
- package/dist/stack/ports.d.ts +2 -0
- package/dist/stack/ports.js +2 -0
- package/dist/stack/prepare.d.ts +20 -4
- package/dist/stack/prepare.js +39 -10
- package/dist/stack/restate-identity.d.ts +1 -1
- package/dist/stack/restate-identity.js +1 -1
- package/dist/stack/stacks.d.ts +47 -0
- package/dist/stack/stacks.js +116 -0
- package/package.json +4 -4
package/dist/stack/commands.js
CHANGED
|
@@ -1,35 +1,90 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile, rm,
|
|
3
|
-
import {
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
|
|
4
5
|
import { CliError } from "../errors.js";
|
|
5
|
-
import {
|
|
6
|
+
import { deriveTenantKey, PROJECT_PRINCIPAL_ID } from "../project/derived-key.js";
|
|
7
|
+
import { readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
|
|
8
|
+
import { findProjectRoot } from "../project/root.js";
|
|
9
|
+
import { seedTenant } from "../project/seed.js";
|
|
6
10
|
import { dockerPreflight, parseComposePs, } from "./docker.js";
|
|
7
11
|
import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
|
|
8
|
-
import { stackImages } from "./images.js";
|
|
12
|
+
import { runtimeImageOverridden, stackImages } from "./images.js";
|
|
9
13
|
import { stackPaths } from "./paths.js";
|
|
10
14
|
import { prepareStack, readStackEnv } from "./prepare.js";
|
|
15
|
+
import { assertStackName, chooseStackName, defaultNylorunRoot, LEGACY_ENTRIES, legacyStack, listStacks, readStackRecord, sanitizeStackName, stackRoot, stacksDir, writeStackRecord, } from "./stacks.js";
|
|
11
16
|
import { mintStudioLogin, studioOrigin } from "./studio-login.js";
|
|
12
|
-
export const STACK_SERVICES = ["postgres", "restate", "s2", "runtime", "studio"];
|
|
13
|
-
const CORE_SERVICES = ["postgres", "restate", "s2", "runtime"];
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
export const STACK_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
|
|
18
|
+
const CORE_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime"];
|
|
19
|
+
export const stackUsage = ` up|start [--name <stack>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
|
|
20
|
+
start the project's stack, creating it, its one Tenant and the Project link
|
|
21
|
+
(.nylorun/link.json, credentials.json) on the first run; print the Runtime and Studio URLs
|
|
22
|
+
and open Studio signed in (in a terminal). --name attaches to (or creates) a named stack;
|
|
23
|
+
--no-link starts a stack without linking the current directory
|
|
24
|
+
down|stop [--name <stack>] stop the stack's containers; keep volumes
|
|
25
|
+
status [--name <stack>] [--json] the stack, its Tenant, services, endpoints and Runtime health
|
|
26
|
+
logs [service] [--name <stack>] [-f] [--tail <n>]
|
|
27
|
+
stack logs (${STACK_SERVICES.join(", ")})
|
|
28
|
+
studio [--name <stack>] [--no-open] sign a browser in to Studio on the stack's Tenant; --no-open prints the login URL; start the stack if it is stopped
|
|
29
|
+
reset [--name <stack>] [--yes] delete the stack's volumes and its Tenant directory; the next start creates a new Tenant
|
|
30
|
+
ls [--json] the stacks on this machine
|
|
31
|
+
delete <stack> --yes remove a stack: containers, volumes and its Host root, with the vault key (KEK)
|
|
32
|
+
legacy stop|delete [--yes] stop or remove the single stack of older releases (Compose project nylorun)`;
|
|
21
33
|
const usageError = (message) => new CliError(message, 2);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
/** The Compose project of stack `name`: `nylorun-<name>`, or `NYLORUN_STACK_PROJECT`. */
|
|
35
|
+
export function stackProject(env, name) {
|
|
36
|
+
const override = env.NYLORUN_STACK_PROJECT?.trim();
|
|
37
|
+
if (!override)
|
|
38
|
+
return `nylorun-${name}`;
|
|
39
|
+
return assertStackName(override, "NYLORUN_STACK_PROJECT");
|
|
27
40
|
}
|
|
28
|
-
function
|
|
41
|
+
function nylorunRoot(deps) {
|
|
42
|
+
return deps.nylorunRoot ?? defaultNylorunRoot();
|
|
43
|
+
}
|
|
44
|
+
async function stackNames(deps) {
|
|
45
|
+
const names = (await listStacks(nylorunRoot(deps))).map((stack) => stack.name);
|
|
46
|
+
return names.length ? ` Stacks on this machine: ${names.join(", ")}.` : "";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Which stack a command acts on: `--name`, then `NYLORUN_STACK`, then the stack of the
|
|
50
|
+
* project's link (format 2); for `start` in a project, then a name from the project
|
|
51
|
+
* directory (`chooseStackName`). `NYLORUN_HOME` sets the Host root whatever the name; its
|
|
52
|
+
* name otherwise comes from its `stack.json`, the project directory or the Host root's own.
|
|
53
|
+
*/
|
|
54
|
+
async function selectStack(deps, options = {}) {
|
|
55
|
+
const { env } = deps;
|
|
56
|
+
const base = nylorunRoot(deps);
|
|
57
|
+
const projectDir = options.noLink ? undefined : findProjectRoot(deps.cwd ?? process.cwd());
|
|
58
|
+
const link = projectDir ? await readProjectLink(projectDir) : undefined;
|
|
59
|
+
const explicit = options.name ?? (env.NYLORUN_STACK?.trim() || undefined);
|
|
60
|
+
if (explicit !== undefined)
|
|
61
|
+
assertStackName(explicit, options.name !== undefined ? "--name" : "NYLORUN_STACK");
|
|
62
|
+
let name = explicit ?? (link?.format === 2 ? link.stack : undefined);
|
|
63
|
+
let root;
|
|
64
|
+
const home = env.NYLORUN_HOME?.trim();
|
|
65
|
+
if (home) {
|
|
66
|
+
root = resolve(home);
|
|
67
|
+
name ??=
|
|
68
|
+
(await readStackRecord(root))?.name ??
|
|
69
|
+
sanitizeStackName(basename(projectDir ?? root));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
if (name === undefined && options.start && projectDir)
|
|
73
|
+
name = await chooseStackName(base, projectDir);
|
|
74
|
+
if (name === undefined)
|
|
75
|
+
throw usageError(options.start
|
|
76
|
+
? `Not in a project: name the stack with "nylorun start --name <stack>" (or NYLORUN_STACK).${await stackNames(deps)}`
|
|
77
|
+
: `No stack selected: run this in a project "nylorun start" linked, pass --name <stack>, or set NYLORUN_STACK.${await stackNames(deps)}`);
|
|
78
|
+
root = stackRoot(base, name);
|
|
79
|
+
}
|
|
80
|
+
assertStackName(name, "The stack name");
|
|
29
81
|
return {
|
|
30
82
|
deps,
|
|
31
|
-
|
|
32
|
-
|
|
83
|
+
name,
|
|
84
|
+
paths: stackPaths(root),
|
|
85
|
+
project: stackProject(env, name),
|
|
86
|
+
...(projectDir ? { projectDir } : {}),
|
|
87
|
+
...(link ? { link } : {}),
|
|
33
88
|
};
|
|
34
89
|
}
|
|
35
90
|
function composeArgs(ctx, ...args) {
|
|
@@ -46,11 +101,12 @@ function composeArgs(ctx, ...args) {
|
|
|
46
101
|
}
|
|
47
102
|
function requireStackFiles(ctx) {
|
|
48
103
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
49
|
-
throw new CliError(`No Nylorun stack under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
|
|
104
|
+
throw new CliError(`No Nylorun stack ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
|
|
50
105
|
}
|
|
51
106
|
export function parseStackFlags(args, allowed, usage) {
|
|
52
107
|
const booleans = new Set();
|
|
53
108
|
const values = new Map();
|
|
109
|
+
const lists = new Map();
|
|
54
110
|
const rest = [];
|
|
55
111
|
for (let index = 0; index < args.length; index += 1) {
|
|
56
112
|
const raw = args[index];
|
|
@@ -70,9 +126,21 @@ export function parseStackFlags(args, allowed, usage) {
|
|
|
70
126
|
values.set(arg, value);
|
|
71
127
|
continue;
|
|
72
128
|
}
|
|
129
|
+
if (allowed.lists?.includes(arg)) {
|
|
130
|
+
const value = args[++index];
|
|
131
|
+
if (value === undefined || value.startsWith("-"))
|
|
132
|
+
throw usageError(`${arg} requires a value.`);
|
|
133
|
+
lists.set(arg, [...(lists.get(arg) ?? []), value]);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
73
136
|
throw usageError(`Unknown option ${raw}. Usage: ${usage}`);
|
|
74
137
|
}
|
|
75
|
-
return { rest, booleans, values };
|
|
138
|
+
return { rest, booleans, values, lists };
|
|
139
|
+
}
|
|
140
|
+
/** `--name` when given. */
|
|
141
|
+
function nameOption(flags) {
|
|
142
|
+
const name = flags.values.get("--name");
|
|
143
|
+
return name === undefined ? {} : { name };
|
|
76
144
|
}
|
|
77
145
|
async function sleep(ms) {
|
|
78
146
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
@@ -105,6 +173,61 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
|
|
|
105
173
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
106
174
|
}
|
|
107
175
|
}
|
|
176
|
+
/** `/v1/admin/status`'s Tenant on the operator listener; undefined when it does not answer. */
|
|
177
|
+
async function fetchTenant(deps, adminUrl, adminKey) {
|
|
178
|
+
if (!adminKey)
|
|
179
|
+
return undefined;
|
|
180
|
+
try {
|
|
181
|
+
const response = await deps.fetch(`${adminUrl}/v1/admin/status`, {
|
|
182
|
+
headers: {
|
|
183
|
+
authorization: `Bearer ${adminKey}`,
|
|
184
|
+
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
185
|
+
accept: "application/json",
|
|
186
|
+
},
|
|
187
|
+
signal: AbortSignal.timeout(3000),
|
|
188
|
+
redirect: "error",
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok)
|
|
191
|
+
return undefined;
|
|
192
|
+
const tenant = (await response.json()).tenant;
|
|
193
|
+
if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
|
|
194
|
+
return undefined;
|
|
195
|
+
return {
|
|
196
|
+
id: typeof tenant.id === "string" ? tenant.id : null,
|
|
197
|
+
name: typeof tenant.name === "string" ? tenant.name : null,
|
|
198
|
+
state: tenant.state,
|
|
199
|
+
...(tenant.cause ? { cause: tenant.cause } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Why the stack's Tenant is unavailable, with the repair the Runtime names. */
|
|
207
|
+
function tenantCauseMessage(ctx, tenant) {
|
|
208
|
+
const cause = tenant.cause;
|
|
209
|
+
const hint = cause.code === "schema-too-new"
|
|
210
|
+
? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the stack's database: update nylorun (npx nylorun@latest start).`
|
|
211
|
+
: "";
|
|
212
|
+
return `The Tenant of stack ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Wait until the stack's Tenant is open: the Runtime creates it on the first start and opens
|
|
216
|
+
* it on later ones. A Tenant that could not be opened has a cause; report it.
|
|
217
|
+
*/
|
|
218
|
+
async function waitForTenant(ctx, adminUrl, adminKey) {
|
|
219
|
+
const deadline = Date.now() + (ctx.deps.healthTimeoutMs ?? 60_000);
|
|
220
|
+
for (;;) {
|
|
221
|
+
const tenant = await fetchTenant(ctx.deps, adminUrl, adminKey);
|
|
222
|
+
if (tenant?.state === "open" && tenant.id)
|
|
223
|
+
return { ...tenant, id: tenant.id };
|
|
224
|
+
if (tenant?.cause)
|
|
225
|
+
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
226
|
+
if (Date.now() > deadline)
|
|
227
|
+
throw new CliError(`The Tenant of stack ${ctx.name} did not open (${adminUrl}/v1/admin/status: ${tenant ? tenant.state : "no answer"}). See "nylorun status" and "nylorun logs runtime".`, 7);
|
|
228
|
+
await sleep(ctx.deps.pollMs ?? 500);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
108
231
|
/**
|
|
109
232
|
* A Runtime started by the removed launcher (`nylorun runtime up`, the
|
|
110
233
|
* `nylorun-runtime` bin) shares host.json; refuse to fight it.
|
|
@@ -135,26 +258,96 @@ function isUp(services, name) {
|
|
|
135
258
|
const service = services.find((s) => s.service === name);
|
|
136
259
|
return service?.state === "running" && (service.health === "" || service.health === "healthy");
|
|
137
260
|
}
|
|
261
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
262
|
+
/** `version` is a SemVer older than `than` (prereleases sort before releases). */
|
|
263
|
+
function isOlder(version, than) {
|
|
264
|
+
return (typeof than === "string" &&
|
|
265
|
+
SEMVER.test(version) &&
|
|
266
|
+
SEMVER.test(than) &&
|
|
267
|
+
compareVersions(version, than) < 0);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Refuse a Runtime older than the stack's database: the Runtime that last ran the stack
|
|
271
|
+
* (host.json `runtimeVersion`) migrated it, and an older one cannot open its Tenant
|
|
272
|
+
* (`schema-too-new`). Checked before Compose replaces anything. Skipped when
|
|
273
|
+
* `NYLORUN_RUNTIME_IMAGE` names the image.
|
|
274
|
+
*/
|
|
275
|
+
async function refuseDowngrade(ctx, allowDowngrade) {
|
|
276
|
+
const pinned = ctx.deps.runtimeVersion;
|
|
277
|
+
const host = await readHostConfig(ctx.paths);
|
|
278
|
+
if (!isOlder(pinned, host?.runtimeVersion))
|
|
279
|
+
return;
|
|
280
|
+
if (allowDowngrade) {
|
|
281
|
+
ctx.deps.err(`Warning: starting Runtime ${pinned} on stack ${ctx.name}, which Runtime ${host.runtimeVersion} last ran. If that Runtime migrated the database, the Tenant stays unavailable (schema-too-new).`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
throw new CliError(`Refusing to start Runtime ${pinned} on stack ${ctx.name}: Runtime ${host.runtimeVersion} last ran it (${ctx.paths.config}), and a Runtime older than the stack's database cannot open its Tenant. Update nylorun (npx nylorun@latest start), or run "nylorun start --allow-downgrade" when both Runtimes use the same database schema.`, 5);
|
|
285
|
+
}
|
|
286
|
+
/** Ports other stacks (and the legacy stack) keep in their `.env`, so a new stack avoids them. */
|
|
287
|
+
async function reservedPorts(ctx) {
|
|
288
|
+
const base = nylorunRoot(ctx.deps);
|
|
289
|
+
const others = (await listStacks(base))
|
|
290
|
+
.filter((stack) => resolve(stack.root) !== ctx.paths.root)
|
|
291
|
+
.map((stack) => stackPaths(stack.root));
|
|
292
|
+
// The legacy stack keeps its .env in stack/, not docker/.
|
|
293
|
+
const legacy = legacyStack(base);
|
|
294
|
+
if (legacy && resolve(base) !== ctx.paths.root)
|
|
295
|
+
others.push(legacy);
|
|
296
|
+
const reserved = new Set();
|
|
297
|
+
for (const paths of others) {
|
|
298
|
+
const persisted = await readStackEnv(paths);
|
|
299
|
+
for (const port of [
|
|
300
|
+
persisted?.runtimePort,
|
|
301
|
+
persisted?.adminPort,
|
|
302
|
+
persisted?.studioPort,
|
|
303
|
+
persisted?.restatePort,
|
|
304
|
+
])
|
|
305
|
+
if (port !== undefined)
|
|
306
|
+
reserved.add(port);
|
|
307
|
+
}
|
|
308
|
+
return reserved;
|
|
309
|
+
}
|
|
138
310
|
async function bringUp(ctx, options) {
|
|
139
311
|
const { deps } = ctx;
|
|
140
312
|
await refuseLauncherRuntime(ctx);
|
|
313
|
+
const overridden = runtimeImageOverridden(deps.env);
|
|
314
|
+
if (!overridden)
|
|
315
|
+
await refuseDowngrade(ctx, options.allowDowngrade ?? false);
|
|
141
316
|
const prepared = await prepareStack({
|
|
142
317
|
paths: ctx.paths,
|
|
318
|
+
name: ctx.name,
|
|
319
|
+
project: ctx.project,
|
|
143
320
|
images: stackImages(deps.env, {
|
|
144
321
|
runtime: deps.runtimeVersion,
|
|
145
322
|
studio: deps.studioVersion,
|
|
146
323
|
}),
|
|
147
324
|
uid: deps.uid,
|
|
148
325
|
gid: deps.gid,
|
|
149
|
-
|
|
326
|
+
// An overriding image's version is unknown: keep the recorded one.
|
|
327
|
+
runtimeVersion: overridden ? undefined : deps.runtimeVersion,
|
|
150
328
|
ports: deps.ports,
|
|
329
|
+
reserved: await reservedPorts(ctx),
|
|
330
|
+
...(deps.env.NYLORUN_DERIVED_PRINCIPALS?.trim()
|
|
331
|
+
? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
|
|
332
|
+
: {}),
|
|
333
|
+
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
151
334
|
});
|
|
335
|
+
const record = await readStackRecord(ctx.paths.root);
|
|
336
|
+
const project = record?.project ?? ctx.projectDir;
|
|
337
|
+
if (record?.name !== ctx.name || record.project !== project)
|
|
338
|
+
await writeStackRecord(ctx.paths.root, { name: ctx.name, ...(project ? { project } : {}) });
|
|
152
339
|
if (prepared.firstRun)
|
|
153
|
-
deps.err(`
|
|
154
|
-
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
155
|
-
if (up !== 0)
|
|
156
|
-
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime" and "nylorun status".`, 7);
|
|
340
|
+
deps.err(`Created stack ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
|
|
157
341
|
const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
|
|
342
|
+
const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
|
|
343
|
+
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
344
|
+
if (up !== 0) {
|
|
345
|
+
// A Runtime whose Tenant cannot open fails readiness; its status names the cause.
|
|
346
|
+
const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
|
|
347
|
+
if (tenant?.cause)
|
|
348
|
+
throw new CliError(tenantCauseMessage(ctx, tenant), 7);
|
|
349
|
+
throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway" and "nylorun status".`, 7);
|
|
350
|
+
}
|
|
158
351
|
await waitForHealth(ctx, runtimeUrl, prepared.host.hostId);
|
|
159
352
|
let studioStarted = false;
|
|
160
353
|
if (options.studio) {
|
|
@@ -165,6 +358,7 @@ async function bringUp(ctx, options) {
|
|
|
165
358
|
}
|
|
166
359
|
return {
|
|
167
360
|
runtimeUrl,
|
|
361
|
+
adminUrl,
|
|
168
362
|
hostId: prepared.host.hostId,
|
|
169
363
|
studioPort: prepared.env.studioPort,
|
|
170
364
|
studioStarted,
|
|
@@ -202,71 +396,139 @@ async function openLogin(ctx, login) {
|
|
|
202
396
|
if (!(await ctx.deps.openBrowser(login)))
|
|
203
397
|
ctx.deps.out(`Sign in ${login}`);
|
|
204
398
|
}
|
|
205
|
-
/**
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
399
|
+
/** The legacy stack is mentioned once per machine (a marker under `stacks/`). */
|
|
400
|
+
async function noteLegacy(deps) {
|
|
401
|
+
const base = nylorunRoot(deps);
|
|
402
|
+
const legacy = legacyStack(base);
|
|
403
|
+
const marker = join(stacksDir(base), ".legacy-noted");
|
|
404
|
+
if (!legacy || existsSync(marker))
|
|
405
|
+
return;
|
|
406
|
+
deps.err(`Note: the single stack of an older release is still on this machine (Compose project nylorun, Host root ${legacy.root}). Stacks are now one per project, and the old one is left as it is: stop it with "npx nylorun legacy stop", or remove it and its Tenants with "npx nylorun legacy delete --yes".`);
|
|
407
|
+
await mkdir(stacksDir(base), { recursive: true, mode: 0o700 });
|
|
408
|
+
await writeFile(marker, "", { mode: 0o600 });
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Link the project to the stack: `.nylorun/link.json` (format 2) and
|
|
412
|
+
* `.nylorun/credentials.json` with the key of the derived principal `project`. The link is
|
|
413
|
+
* rewritten only when the stack, its URL, Host or Tenant changed; a new link seeds the
|
|
414
|
+
* Tenant from the project's `.env`.
|
|
415
|
+
*/
|
|
416
|
+
async function linkProject(ctx, started, tenantId) {
|
|
417
|
+
const { deps, projectDir, link } = ctx;
|
|
418
|
+
if (link && link.format < 2)
|
|
419
|
+
deps.err(`This project was linked to a Tenant on ${link.hostUrl}, a stack of an older Runtime. It gets its own stack, ${ctx.name}, and a new link; model credentials come from .env (or Studio), and agents register again (npm run dev).`);
|
|
420
|
+
const applicationKey = deriveTenantKey(started.adminKey, tenantId, PROJECT_PRINCIPAL_ID);
|
|
421
|
+
const fresh = link?.format !== 2 ||
|
|
422
|
+
link.stack !== ctx.name ||
|
|
423
|
+
link.hostUrl !== started.runtimeUrl ||
|
|
424
|
+
link.hostId !== started.hostId ||
|
|
425
|
+
link.tenantId !== tenantId;
|
|
426
|
+
if (fresh)
|
|
427
|
+
await writeProjectLink(projectDir, {
|
|
428
|
+
stack: ctx.name,
|
|
429
|
+
hostUrl: started.runtimeUrl,
|
|
430
|
+
hostId: started.hostId,
|
|
431
|
+
tenantId,
|
|
432
|
+
});
|
|
433
|
+
const credentials = await readProjectCredentials(projectDir);
|
|
434
|
+
if (fresh ||
|
|
435
|
+
credentials?.applicationKey !== applicationKey ||
|
|
436
|
+
credentials.principalId !== PROJECT_PRINCIPAL_ID)
|
|
437
|
+
await writeProjectCredentials(projectDir, {
|
|
438
|
+
applicationKey,
|
|
439
|
+
principalId: PROJECT_PRINCIPAL_ID,
|
|
440
|
+
});
|
|
441
|
+
if (!fresh)
|
|
442
|
+
return;
|
|
443
|
+
deps.err(`Linked ${projectDir} to stack ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
|
|
444
|
+
try {
|
|
445
|
+
const seeded = await seedTenant({
|
|
446
|
+
fetch: deps.fetch,
|
|
447
|
+
runtimeUrl: started.runtimeUrl,
|
|
448
|
+
applicationKey,
|
|
449
|
+
projectRoot: projectDir,
|
|
450
|
+
});
|
|
451
|
+
const set = [...seeded.applied, ...(seeded.model ? [`model ${seeded.model}`] : [])];
|
|
452
|
+
if (set.length)
|
|
453
|
+
deps.err(`Seeded the Tenant from .env: ${set.join(", ")}.`);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
deps.err(`Warning: could not seed the Tenant from .env (${error instanceof Error ? error.message : String(error)}). Set the model in Studio or with "npx @nylorun/cli configure".`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const START_USAGE = "nylorun start [--name <stack>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
|
|
460
|
+
async function start(deps, args) {
|
|
461
|
+
const flags = parseStackFlags(args, {
|
|
462
|
+
booleans: [
|
|
463
|
+
"--no-studio",
|
|
464
|
+
"--no-open",
|
|
465
|
+
"--no-link",
|
|
466
|
+
"--allow-downgrade",
|
|
467
|
+
"--studio-embed-origin-reset",
|
|
468
|
+
],
|
|
469
|
+
values: ["--name"],
|
|
470
|
+
lists: ["--studio-embed-origin"],
|
|
471
|
+
}, START_USAGE);
|
|
210
472
|
if (flags.rest.length)
|
|
211
|
-
throw usageError(`Usage: ${
|
|
212
|
-
await
|
|
213
|
-
|
|
214
|
-
|
|
473
|
+
throw usageError(`Usage: ${START_USAGE}`);
|
|
474
|
+
const ctx = await selectStack(deps, {
|
|
475
|
+
...nameOption(flags),
|
|
476
|
+
start: true,
|
|
477
|
+
noLink: flags.booleans.has("--no-link"),
|
|
478
|
+
});
|
|
479
|
+
await dockerPreflight(deps.docker);
|
|
480
|
+
await noteLegacy(deps);
|
|
481
|
+
const embedOrigins = flags.lists.get("--studio-embed-origin") ?? [];
|
|
482
|
+
const resetEmbed = flags.booleans.has("--studio-embed-origin-reset");
|
|
483
|
+
const started = await bringUp(ctx, {
|
|
484
|
+
studio: !flags.booleans.has("--no-studio"),
|
|
485
|
+
allowDowngrade: flags.booleans.has("--allow-downgrade"),
|
|
486
|
+
...(embedOrigins.length || resetEmbed
|
|
487
|
+
? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
|
|
488
|
+
: {}),
|
|
489
|
+
});
|
|
490
|
+
const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
|
|
491
|
+
if (ctx.projectDir)
|
|
492
|
+
await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
|
|
493
|
+
deps.out(`Stack ${ctx.name} (${ctx.paths.root})`);
|
|
494
|
+
deps.out(`Tenant ${tenant.id}${tenant.name ? ` (${tenant.name})` : ""}`);
|
|
495
|
+
deps.out(`Runtime ${started.runtimeUrl}`);
|
|
215
496
|
if (started.studioStarted) {
|
|
216
|
-
|
|
217
|
-
if (opensBrowser(
|
|
497
|
+
deps.out(`Studio ${studioOrigin(started.studioPort)}`);
|
|
498
|
+
if (opensBrowser(deps, flags)) {
|
|
218
499
|
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
219
500
|
if (login)
|
|
220
|
-
await openLogin(ctx, login);
|
|
501
|
+
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
221
502
|
}
|
|
222
503
|
else
|
|
223
|
-
|
|
504
|
+
deps.err(STUDIO_SIGN_IN_HINT);
|
|
224
505
|
}
|
|
225
|
-
if ((await adminTenantCount(ctx.deps, started.runtimeUrl, started.adminKey)) === 0)
|
|
226
|
-
ctx.deps.err(TENANT_HINT);
|
|
227
506
|
return 0;
|
|
228
507
|
}
|
|
229
|
-
async function stop(
|
|
230
|
-
|
|
231
|
-
|
|
508
|
+
async function stop(deps, args) {
|
|
509
|
+
const usage = "nylorun stop [--name <stack>]";
|
|
510
|
+
const flags = parseStackFlags(args, { values: ["--name"] }, usage);
|
|
511
|
+
if (flags.rest.length)
|
|
512
|
+
throw usageError(`Usage: ${usage}`);
|
|
513
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
232
514
|
requireStackFiles(ctx);
|
|
233
|
-
await dockerPreflight(
|
|
234
|
-
const code = await
|
|
515
|
+
await dockerPreflight(deps.docker);
|
|
516
|
+
const code = await deps.docker.stream(composeArgs(ctx, "stop"));
|
|
235
517
|
if (code !== 0)
|
|
236
518
|
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
237
|
-
|
|
519
|
+
deps.out(`Stopped stack ${ctx.name}; volumes are kept.`);
|
|
238
520
|
return 0;
|
|
239
521
|
}
|
|
240
|
-
async function adminTenantCount(deps, runtimeUrl, adminKey) {
|
|
241
|
-
if (!adminKey)
|
|
242
|
-
return undefined;
|
|
243
|
-
try {
|
|
244
|
-
const response = await deps.fetch(`${runtimeUrl}/v1/admin/status`, {
|
|
245
|
-
headers: {
|
|
246
|
-
authorization: `Bearer ${adminKey}`,
|
|
247
|
-
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
248
|
-
accept: "application/json",
|
|
249
|
-
},
|
|
250
|
-
signal: AbortSignal.timeout(3000),
|
|
251
|
-
redirect: "error",
|
|
252
|
-
});
|
|
253
|
-
if (!response.ok)
|
|
254
|
-
return undefined;
|
|
255
|
-
const body = (await response.json());
|
|
256
|
-
return Array.isArray(body.tenants) ? body.tenants.length : undefined;
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
522
|
async function stackStatus(ctx) {
|
|
263
523
|
const base = {
|
|
524
|
+
name: ctx.name,
|
|
264
525
|
project: ctx.project,
|
|
265
526
|
home: ctx.paths.root,
|
|
266
527
|
state: "absent",
|
|
267
528
|
runtime: { healthy: false },
|
|
268
529
|
studio: { state: "absent" },
|
|
269
530
|
restate: {},
|
|
531
|
+
gateway: { state: "absent", healthy: false },
|
|
270
532
|
services: [],
|
|
271
533
|
};
|
|
272
534
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
@@ -280,53 +542,83 @@ async function stackStatus(ctx) {
|
|
|
280
542
|
const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
|
|
281
543
|
const host = await readHostConfig(ctx.paths);
|
|
282
544
|
const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
|
|
283
|
-
|
|
284
|
-
|
|
545
|
+
// The Admin API answers on the operator port; an older stack has only the Runtime port.
|
|
546
|
+
const adminUrl = persisted.adminPort
|
|
547
|
+
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
548
|
+
: runtimeUrl;
|
|
549
|
+
const tenant = healthy && adminUrl
|
|
550
|
+
? await fetchTenant(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
|
|
285
551
|
: undefined;
|
|
286
552
|
const studio = services.find((s) => s.service === "studio");
|
|
553
|
+
const gateway = services.find((s) => s.service === "gateway");
|
|
287
554
|
return {
|
|
288
555
|
...base,
|
|
289
556
|
state: services.some((s) => s.state === "running") ? "running" : "stopped",
|
|
290
557
|
runtime: {
|
|
291
558
|
...(runtimeUrl ? { url: runtimeUrl } : {}),
|
|
559
|
+
...(persisted.adminPort && adminUrl ? { adminUrl } : {}),
|
|
292
560
|
healthy,
|
|
293
561
|
...(health?.version ? { version: health.version } : {}),
|
|
294
562
|
...(health?.hostId ? { hostId: health.hostId } : {}),
|
|
295
|
-
...(tenants !== undefined ? { tenants } : {}),
|
|
296
563
|
},
|
|
564
|
+
...(tenant ? { tenant } : {}),
|
|
297
565
|
studio: {
|
|
298
566
|
...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
|
|
299
567
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
568
|
+
...(persisted.studioFrameAncestors
|
|
569
|
+
? { embedOrigins: persisted.studioFrameAncestors }
|
|
570
|
+
: {}),
|
|
300
571
|
},
|
|
301
572
|
restate: persisted.restatePort
|
|
302
573
|
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}` }
|
|
303
574
|
: {},
|
|
575
|
+
gateway: {
|
|
576
|
+
state: gateway ? [gateway.state, gateway.health].filter(Boolean).join(", ") : "absent",
|
|
577
|
+
healthy: isUp(services, "gateway"),
|
|
578
|
+
},
|
|
304
579
|
services,
|
|
305
580
|
};
|
|
306
581
|
}
|
|
307
582
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
308
|
-
export async function readStackStatus(deps) {
|
|
309
|
-
return await stackStatus(
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const
|
|
583
|
+
export async function readStackStatus(deps, options = {}) {
|
|
584
|
+
return await stackStatus(await selectStack(deps, options));
|
|
585
|
+
}
|
|
586
|
+
function describeTenant(tenant) {
|
|
587
|
+
const who = `${tenant.id ?? "(no id yet)"}${tenant.name ? ` (${tenant.name})` : ""}`;
|
|
588
|
+
if (tenant.state === "open")
|
|
589
|
+
return `${who} open`;
|
|
590
|
+
return tenant.cause
|
|
591
|
+
? `${who} unavailable: ${tenant.cause.code}: ${tenant.cause.message} ${tenant.cause.repair}`
|
|
592
|
+
: `${who} unavailable (opening)`;
|
|
593
|
+
}
|
|
594
|
+
async function status(deps, args) {
|
|
595
|
+
const usage = "nylorun status [--name <stack>] [--json]";
|
|
596
|
+
const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--name"] }, usage);
|
|
313
597
|
if (flags.rest.length)
|
|
314
|
-
throw usageError(
|
|
315
|
-
const result = await stackStatus(
|
|
316
|
-
const { out } =
|
|
598
|
+
throw usageError(`Usage: ${usage}`);
|
|
599
|
+
const result = await stackStatus(await selectStack(deps, nameOption(flags)));
|
|
600
|
+
const { out } = deps;
|
|
317
601
|
if (flags.booleans.has("--json")) {
|
|
318
602
|
out(JSON.stringify(result, null, 2));
|
|
319
603
|
}
|
|
320
604
|
else if (result.state === "absent") {
|
|
321
|
-
out(`Stack absent (
|
|
605
|
+
out(`Stack ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
|
|
322
606
|
}
|
|
323
607
|
else {
|
|
324
|
-
out(`Stack ${result.state} (project ${result.project})`);
|
|
608
|
+
out(`Stack ${result.name} ${result.state} (Compose project ${result.project})`);
|
|
609
|
+
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
610
|
+
if (result.tenant)
|
|
611
|
+
out(`Tenant ${describeTenant(result.tenant)}`);
|
|
325
612
|
const runtimeDetail = result.runtime.healthy
|
|
326
|
-
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}
|
|
613
|
+
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
|
|
327
614
|
: "not answering";
|
|
328
615
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
616
|
+
if (result.runtime.adminUrl)
|
|
617
|
+
out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
|
|
329
618
|
out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
|
|
619
|
+
if (result.studio.embedOrigins?.length)
|
|
620
|
+
out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
|
|
621
|
+
out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
|
|
330
622
|
if (result.restate.url)
|
|
331
623
|
out(`Restate UI ${result.restate.url}`);
|
|
332
624
|
out(`Services ${STACK_SERVICES.map((name) => {
|
|
@@ -336,13 +628,12 @@ async function status(ctx, args) {
|
|
|
336
628
|
: "absent";
|
|
337
629
|
return `${name} ${state}`;
|
|
338
630
|
}).join(", ")}`);
|
|
339
|
-
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
340
631
|
}
|
|
341
632
|
return result.runtime.healthy ? 0 : 3;
|
|
342
633
|
}
|
|
343
|
-
async function logs(
|
|
344
|
-
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [-f] [--tail <n>]`;
|
|
345
|
-
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail"], aliases: { "-f": "--follow" } }, usage);
|
|
634
|
+
async function logs(deps, args) {
|
|
635
|
+
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [--name <stack>] [-f] [--tail <n>]`;
|
|
636
|
+
const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail", "--name"], aliases: { "-f": "--follow" } }, usage);
|
|
346
637
|
if (flags.rest.length > 1)
|
|
347
638
|
throw usageError(`Usage: ${usage}`);
|
|
348
639
|
const service = flags.rest[0];
|
|
@@ -351,19 +642,22 @@ async function logs(ctx, args) {
|
|
|
351
642
|
const tail = flags.values.get("--tail");
|
|
352
643
|
if (tail !== undefined && !/^\d+$/.test(tail))
|
|
353
644
|
throw usageError(`Invalid --tail: ${tail}`);
|
|
645
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
354
646
|
requireStackFiles(ctx);
|
|
355
|
-
await dockerPreflight(
|
|
356
|
-
return await
|
|
647
|
+
await dockerPreflight(deps.docker);
|
|
648
|
+
return await deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
|
|
357
649
|
}
|
|
358
|
-
async function reset(
|
|
359
|
-
const
|
|
650
|
+
async function reset(deps, args) {
|
|
651
|
+
const usage = "nylorun reset [--name <stack>] [--yes]";
|
|
652
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], values: ["--name"], aliases: { "-y": "--yes" } }, usage);
|
|
360
653
|
if (flags.rest.length)
|
|
361
|
-
throw usageError(
|
|
362
|
-
const
|
|
654
|
+
throw usageError(`Usage: ${usage}`);
|
|
655
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
656
|
+
const { paths } = ctx;
|
|
363
657
|
if (!flags.booleans.has("--yes")) {
|
|
364
|
-
const question = `Delete
|
|
658
|
+
const question = `Delete stack ${ctx.name}'s volumes (Compose project ${ctx.project}) and its Tenant directory ${paths.tenant} and vault key ${paths.vaultKey}? Its Tenant's data is lost and the next start creates a new Tenant. [y/N] `;
|
|
365
659
|
if (!deps.confirm)
|
|
366
|
-
throw usageError(
|
|
660
|
+
throw usageError(`nylorun reset deletes stack ${ctx.name}'s Tenant and all its data; pass --yes to confirm when not in a terminal.`);
|
|
367
661
|
if (!(await deps.confirm(question))) {
|
|
368
662
|
deps.err("Reset cancelled.");
|
|
369
663
|
return 1;
|
|
@@ -375,9 +669,145 @@ async function reset(ctx, args) {
|
|
|
375
669
|
if (code !== 0)
|
|
376
670
|
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
377
671
|
}
|
|
378
|
-
await rm(paths.
|
|
379
|
-
await mkdir(paths.
|
|
380
|
-
|
|
672
|
+
await rm(paths.tenant, { recursive: true, force: true });
|
|
673
|
+
await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
|
|
674
|
+
// The vault key goes with the Tenant's data; the next start writes a new one.
|
|
675
|
+
await rm(paths.keys, { recursive: true, force: true });
|
|
676
|
+
deps.out(`Reset stack ${ctx.name}: volumes and the Tenant directory deleted. Run "nylorun start" to start again with a new Tenant (it relinks the project).`);
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
/** Compose projects on this Docker engine and whether any container runs; undefined without Docker. */
|
|
680
|
+
async function composeProjects(docker) {
|
|
681
|
+
const result = await docker.run(["compose", "ls", "--all", "--format", "json"]);
|
|
682
|
+
if (result.code !== 0)
|
|
683
|
+
return undefined;
|
|
684
|
+
try {
|
|
685
|
+
const text = result.stdout.trim();
|
|
686
|
+
const rows = (text === "" ? [] : JSON.parse(text));
|
|
687
|
+
return new Map(rows.map((row) => [
|
|
688
|
+
String(row.Name ?? ""),
|
|
689
|
+
/running/.test(String(row.Status ?? "")) ? "running" : "stopped",
|
|
690
|
+
]));
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function ls(deps, args) {
|
|
697
|
+
const usage = "nylorun ls [--json]";
|
|
698
|
+
const flags = parseStackFlags(args, { booleans: ["--json"] }, usage);
|
|
699
|
+
if (flags.rest.length)
|
|
700
|
+
throw usageError(`Usage: ${usage}`);
|
|
701
|
+
const base = nylorunRoot(deps);
|
|
702
|
+
const projects = await composeProjects(deps.docker);
|
|
703
|
+
const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
|
|
704
|
+
const stacks = [];
|
|
705
|
+
for (const entry of await listStacks(base)) {
|
|
706
|
+
const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
|
|
707
|
+
stacks.push({
|
|
708
|
+
name: entry.name,
|
|
709
|
+
root: entry.root,
|
|
710
|
+
...(entry.record?.project ? { project: entry.record.project } : {}),
|
|
711
|
+
state: state(`nylorun-${entry.name}`),
|
|
712
|
+
...(persisted.runtimePort
|
|
713
|
+
? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
|
|
714
|
+
: {}),
|
|
715
|
+
...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
|
|
716
|
+
ports: {
|
|
717
|
+
...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
|
|
718
|
+
...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
|
|
719
|
+
...(persisted.studioPort ? { studio: persisted.studioPort } : {}),
|
|
720
|
+
...(persisted.restatePort ? { restate: persisted.restatePort } : {}),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
const legacy = legacyStack(base);
|
|
725
|
+
const legacyView = legacy
|
|
726
|
+
? { root: legacy.root, project: legacy.project, state: state(legacy.project) }
|
|
727
|
+
: undefined;
|
|
728
|
+
if (flags.booleans.has("--json")) {
|
|
729
|
+
deps.out(JSON.stringify({ stacks, ...(legacyView ? { legacy: legacyView } : {}) }, null, 2));
|
|
730
|
+
return 0;
|
|
731
|
+
}
|
|
732
|
+
if (stacks.length === 0)
|
|
733
|
+
deps.out(`No stacks under ${stacksDir(base)}. Run "npx nylorun start" in a project.`);
|
|
734
|
+
else {
|
|
735
|
+
const width = Math.max(4, ...stacks.map((stack) => stack.name.length)) + 2;
|
|
736
|
+
deps.out(`${"NAME".padEnd(width)}${"STATE".padEnd(9)}${"RUNTIME".padEnd(24)}${"STUDIO".padEnd(24)}PROJECT`);
|
|
737
|
+
for (const stack of stacks)
|
|
738
|
+
deps.out(`${stack.name.padEnd(width)}${stack.state.padEnd(9)}${(stack.runtimeUrl ?? "-").padEnd(24)}${(stack.studioUrl ?? "-").padEnd(24)}${stack.project ?? "-"}`);
|
|
739
|
+
}
|
|
740
|
+
if (legacyView)
|
|
741
|
+
deps.out(`Legacy stack: Compose project nylorun, Host root ${legacyView.root} (${legacyView.state}); "nylorun legacy stop|delete" handles it.`);
|
|
742
|
+
return 0;
|
|
743
|
+
}
|
|
744
|
+
async function deleteStack(deps, args) {
|
|
745
|
+
const usage = "nylorun delete <stack> --yes";
|
|
746
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
|
|
747
|
+
const [name, ...extra] = flags.rest;
|
|
748
|
+
if (name === undefined || extra.length)
|
|
749
|
+
throw usageError(`Usage: ${usage}`);
|
|
750
|
+
assertStackName(name, "The stack name");
|
|
751
|
+
const base = nylorunRoot(deps);
|
|
752
|
+
const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(stackRoot(base, name)) };
|
|
753
|
+
if (!existsSync(ctx.paths.root))
|
|
754
|
+
throw new CliError(`No stack ${name} under ${stacksDir(base)}. See "nylorun ls".`, 3);
|
|
755
|
+
if (!flags.booleans.has("--yes"))
|
|
756
|
+
throw usageError(`nylorun delete removes stack ${name}: its containers, volumes and Host root ${ctx.paths.root}, with the Tenant's vault key (KEK) and all its data. This cannot be undone; pass --yes to confirm.`);
|
|
757
|
+
await dockerPreflight(deps.docker);
|
|
758
|
+
const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
|
|
759
|
+
const code = await deps.docker.stream(files
|
|
760
|
+
? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
|
|
761
|
+
: ["compose", "--project-name", ctx.project, "down", "--volumes", "--remove-orphans"]);
|
|
762
|
+
if (code !== 0)
|
|
763
|
+
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
764
|
+
await rm(ctx.paths.root, { recursive: true, force: true });
|
|
765
|
+
deps.out(`Deleted stack ${name}: its containers, volumes and Host root (vault key included).`);
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
768
|
+
function legacyCompose(legacy, ...args) {
|
|
769
|
+
return [
|
|
770
|
+
"compose",
|
|
771
|
+
"--project-name",
|
|
772
|
+
legacy.project,
|
|
773
|
+
"--file",
|
|
774
|
+
legacy.compose,
|
|
775
|
+
"--env-file",
|
|
776
|
+
legacy.env,
|
|
777
|
+
...args,
|
|
778
|
+
];
|
|
779
|
+
}
|
|
780
|
+
async function legacy(deps, args) {
|
|
781
|
+
const usage = "nylorun legacy stop|delete [--yes]";
|
|
782
|
+
const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
|
|
783
|
+
const [action, ...extra] = flags.rest;
|
|
784
|
+
if ((action !== "stop" && action !== "delete") || extra.length)
|
|
785
|
+
throw usageError(`Usage: ${usage}`);
|
|
786
|
+
if (action === "stop" && flags.booleans.has("--yes"))
|
|
787
|
+
throw usageError(`Usage: ${usage}`);
|
|
788
|
+
const base = nylorunRoot(deps);
|
|
789
|
+
const stack = legacyStack(base);
|
|
790
|
+
if (!stack) {
|
|
791
|
+
deps.out(`No legacy stack under ${base} (no stack/compose.yaml).`);
|
|
792
|
+
return 0;
|
|
793
|
+
}
|
|
794
|
+
if (action === "stop") {
|
|
795
|
+
await dockerPreflight(deps.docker);
|
|
796
|
+
const code = await deps.docker.stream(legacyCompose(stack, "stop"));
|
|
797
|
+
if (code !== 0)
|
|
798
|
+
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
799
|
+
deps.out("Stopped the legacy stack (Compose project nylorun); its volumes are kept.");
|
|
800
|
+
return 0;
|
|
801
|
+
}
|
|
802
|
+
if (!flags.booleans.has("--yes"))
|
|
803
|
+
throw usageError(`nylorun legacy delete removes the legacy stack (Compose project nylorun): its containers, volumes, every Tenant on it with their vault keys, and its files under ${base} (${LEGACY_ENTRIES.join(", ")}). ${stacksDir(base)} is kept. Pass --yes to confirm.`);
|
|
804
|
+
await dockerPreflight(deps.docker);
|
|
805
|
+
const code = await deps.docker.stream(legacyCompose(stack, "down", "--volumes", "--remove-orphans"));
|
|
806
|
+
if (code !== 0)
|
|
807
|
+
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
808
|
+
for (const entry of LEGACY_ENTRIES)
|
|
809
|
+
await rm(join(base, entry), { recursive: true, force: true });
|
|
810
|
+
deps.out(`Deleted the legacy stack: its containers, volumes and files under ${base}; ${stacksDir(base)} is kept.`);
|
|
381
811
|
return 0;
|
|
382
812
|
}
|
|
383
813
|
/** The stack when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
|
|
@@ -392,7 +822,7 @@ async function runningStack(ctx, options) {
|
|
|
392
822
|
if (typeof host?.hostId !== "string")
|
|
393
823
|
return undefined;
|
|
394
824
|
const services = await composePs(ctx);
|
|
395
|
-
if (!isUp(services, "runtime"))
|
|
825
|
+
if (!isUp(services, "runtime") || !isUp(services, "gateway"))
|
|
396
826
|
return undefined;
|
|
397
827
|
const studioUp = isUp(services, "studio");
|
|
398
828
|
if (options.studio && !studioUp)
|
|
@@ -402,8 +832,12 @@ async function runningStack(ctx, options) {
|
|
|
402
832
|
if (health?.status !== "ok" || health.hostId !== host.hostId)
|
|
403
833
|
return undefined;
|
|
404
834
|
return {
|
|
835
|
+
name: ctx.name,
|
|
405
836
|
home: ctx.paths.root,
|
|
406
837
|
runtimeUrl,
|
|
838
|
+
adminUrl: persisted.adminPort
|
|
839
|
+
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
840
|
+
: runtimeUrl,
|
|
407
841
|
hostId: host.hostId,
|
|
408
842
|
adminKey,
|
|
409
843
|
studioPort: persisted.studioPort,
|
|
@@ -411,22 +845,18 @@ async function runningStack(ctx, options) {
|
|
|
411
845
|
started: false,
|
|
412
846
|
};
|
|
413
847
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
* `start` code path without its own output. Compose progress
|
|
417
|
-
* still streams, since the first run pulls images.
|
|
418
|
-
*/
|
|
419
|
-
export async function ensureStack(deps, options) {
|
|
420
|
-
const ctx = context(deps);
|
|
421
|
-
await dockerPreflight(deps.docker);
|
|
848
|
+
async function ensureSelected(ctx, options) {
|
|
849
|
+
await dockerPreflight(ctx.deps.docker);
|
|
422
850
|
const running = await runningStack(ctx, options);
|
|
423
851
|
if (running)
|
|
424
852
|
return running;
|
|
425
853
|
const runtimeWasUp = isUp(existsSync(ctx.paths.compose) ? await composePs(ctx) : [], "runtime");
|
|
426
854
|
const started = await bringUp(ctx, options);
|
|
427
855
|
return {
|
|
856
|
+
name: ctx.name,
|
|
428
857
|
home: ctx.paths.root,
|
|
429
858
|
runtimeUrl: started.runtimeUrl,
|
|
859
|
+
adminUrl: started.adminUrl,
|
|
430
860
|
hostId: started.hostId,
|
|
431
861
|
adminKey: started.adminKey,
|
|
432
862
|
studioPort: started.studioPort,
|
|
@@ -434,6 +864,14 @@ export async function ensureStack(deps, options) {
|
|
|
434
864
|
started: !runtimeWasUp,
|
|
435
865
|
};
|
|
436
866
|
}
|
|
867
|
+
/**
|
|
868
|
+
* Start the selected stack unless it is already running (`nylorun studio`): the
|
|
869
|
+
* `start` code path without its output, its Tenant wait or the Project link. Compose
|
|
870
|
+
* progress still streams, since the first run pulls images.
|
|
871
|
+
*/
|
|
872
|
+
export async function ensureStack(deps, options) {
|
|
873
|
+
return await ensureSelected(await selectStack(deps, options.name === undefined ? {} : { name: options.name }), options);
|
|
874
|
+
}
|
|
437
875
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
438
876
|
export function withNext(loginUrl, next) {
|
|
439
877
|
if (!next)
|
|
@@ -442,7 +880,7 @@ export function withNext(loginUrl, next) {
|
|
|
442
880
|
url.searchParams.set("next", next);
|
|
443
881
|
return url.toString();
|
|
444
882
|
}
|
|
445
|
-
/** The Studio page
|
|
883
|
+
/** The Studio page of the stack's Tenant, as a login `next` path. */
|
|
446
884
|
export function tenantStudioPath(tenantId) {
|
|
447
885
|
return `/tenants/${encodeURIComponent(tenantId)}`;
|
|
448
886
|
}
|
|
@@ -451,33 +889,44 @@ export function tenantStudioPath(tenantId) {
|
|
|
451
889
|
* Undefined (after a warning) when Studio does not answer.
|
|
452
890
|
*/
|
|
453
891
|
export async function studioLoginUrl(deps, stack, next) {
|
|
454
|
-
const login = await tryStudioLogin(
|
|
892
|
+
const login = await tryStudioLogin({ deps }, stack.studioPort, stack.adminKey);
|
|
455
893
|
return login === undefined ? undefined : withNext(login, next);
|
|
456
894
|
}
|
|
457
|
-
async function studio(
|
|
458
|
-
const
|
|
895
|
+
async function studio(deps, args, options = {}) {
|
|
896
|
+
const usage = "nylorun studio [--name <stack>] [--no-open]";
|
|
897
|
+
const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--name"] }, usage);
|
|
459
898
|
if (flags.rest.length)
|
|
460
|
-
throw usageError(
|
|
461
|
-
const
|
|
899
|
+
throw usageError(`Usage: ${usage}`);
|
|
900
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
901
|
+
const stack = await ensureSelected(ctx, { studio: true });
|
|
462
902
|
if (stack.started)
|
|
463
|
-
|
|
903
|
+
deps.out(`Runtime ${stack.runtimeUrl}`);
|
|
464
904
|
if (!stack.studioUp)
|
|
465
905
|
throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
|
|
466
|
-
|
|
906
|
+
// Studio serves the stack's one Tenant; the link's Tenant id stands in while it is opening.
|
|
907
|
+
let next = options.next;
|
|
908
|
+
if (next === undefined) {
|
|
909
|
+
const tenant = await fetchTenant(deps, stack.adminUrl, stack.adminKey);
|
|
910
|
+
const linked = ctx.link?.format === 2 && ctx.link.stack === ctx.name ? ctx.link.tenantId : undefined;
|
|
911
|
+
const tenantId = tenant?.id ?? linked;
|
|
912
|
+
if (tenantId)
|
|
913
|
+
next = tenantStudioPath(tenantId);
|
|
914
|
+
}
|
|
915
|
+
const login = await studioLoginUrl(deps, stack, next);
|
|
467
916
|
if (!login)
|
|
468
917
|
return 1;
|
|
469
918
|
if (flags.booleans.has("--no-open")) {
|
|
470
|
-
|
|
919
|
+
deps.out(`Studio ${login}`);
|
|
471
920
|
return 0;
|
|
472
921
|
}
|
|
473
922
|
const origin = studioOrigin(stack.studioPort);
|
|
474
|
-
|
|
475
|
-
await openLogin(
|
|
923
|
+
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
924
|
+
await openLogin({ deps }, login);
|
|
476
925
|
return 0;
|
|
477
926
|
}
|
|
478
|
-
/** `nylorun studio`, landing on `next`
|
|
927
|
+
/** `nylorun studio`, landing on `next` when given, else on the stack's Tenant. */
|
|
479
928
|
export async function runStudioCommand(args, deps, options = {}) {
|
|
480
|
-
return await studio(
|
|
929
|
+
return await studio(deps, args, options);
|
|
481
930
|
}
|
|
482
931
|
const COMMANDS = {
|
|
483
932
|
start,
|
|
@@ -488,15 +937,18 @@ const COMMANDS = {
|
|
|
488
937
|
status,
|
|
489
938
|
logs,
|
|
490
939
|
reset,
|
|
491
|
-
studio: (
|
|
940
|
+
studio: (deps, args) => studio(deps, args),
|
|
941
|
+
ls,
|
|
942
|
+
delete: deleteStack,
|
|
943
|
+
legacy,
|
|
492
944
|
};
|
|
493
945
|
export function isStackCommand(name) {
|
|
494
946
|
return name !== undefined && Object.hasOwn(COMMANDS, name);
|
|
495
947
|
}
|
|
496
|
-
/** Run one stack command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`). */
|
|
948
|
+
/** Run one stack command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`, `ls`, `delete`, `legacy`). */
|
|
497
949
|
export async function runStackCommand(name, args, deps) {
|
|
498
950
|
const command = COMMANDS[name];
|
|
499
951
|
if (!command)
|
|
500
952
|
throw usageError(`Unknown stack command ${name}.\n${stackUsage}`);
|
|
501
|
-
return await command(
|
|
953
|
+
return await command(deps, args);
|
|
502
954
|
}
|