nylorun 0.3.0-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 +42 -0
- package/README.md +136 -60
- 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 +38 -15
- package/dist/stack/commands.js +517 -144
- package/dist/stack/compose-file.d.ts +30 -11
- package/dist/stack/compose-file.js +84 -16
- package/dist/stack/env-file.d.ts +19 -3
- package/dist/stack/env-file.js +44 -4
- package/dist/stack/host-files.d.ts +9 -0
- package/dist/stack/host-files.js +24 -1
- 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/prepare.d.ts +10 -3
- package/dist/stack/prepare.js +23 -12
- 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,38 +1,90 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readFile, rm,
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
3
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
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
|
-
down|stop
|
|
20
|
-
status [--
|
|
21
|
-
logs [service] [-f] [--tail <n>]
|
|
22
|
-
|
|
23
|
-
|
|
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)`;
|
|
24
33
|
const usageError = (message) => new CliError(message, 2);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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");
|
|
30
40
|
}
|
|
31
|
-
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");
|
|
32
81
|
return {
|
|
33
82
|
deps,
|
|
34
|
-
|
|
35
|
-
|
|
83
|
+
name,
|
|
84
|
+
paths: stackPaths(root),
|
|
85
|
+
project: stackProject(env, name),
|
|
86
|
+
...(projectDir ? { projectDir } : {}),
|
|
87
|
+
...(link ? { link } : {}),
|
|
36
88
|
};
|
|
37
89
|
}
|
|
38
90
|
function composeArgs(ctx, ...args) {
|
|
@@ -49,7 +101,7 @@ function composeArgs(ctx, ...args) {
|
|
|
49
101
|
}
|
|
50
102
|
function requireStackFiles(ctx) {
|
|
51
103
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
52
|
-
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);
|
|
53
105
|
}
|
|
54
106
|
export function parseStackFlags(args, allowed, usage) {
|
|
55
107
|
const booleans = new Set();
|
|
@@ -85,6 +137,11 @@ export function parseStackFlags(args, allowed, usage) {
|
|
|
85
137
|
}
|
|
86
138
|
return { rest, booleans, values, lists };
|
|
87
139
|
}
|
|
140
|
+
/** `--name` when given. */
|
|
141
|
+
function nameOption(flags) {
|
|
142
|
+
const name = flags.values.get("--name");
|
|
143
|
+
return name === undefined ? {} : { name };
|
|
144
|
+
}
|
|
88
145
|
async function sleep(ms) {
|
|
89
146
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
90
147
|
}
|
|
@@ -116,6 +173,61 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
|
|
|
116
173
|
await sleep(ctx.deps.pollMs ?? 500);
|
|
117
174
|
}
|
|
118
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
|
+
}
|
|
119
231
|
/**
|
|
120
232
|
* A Runtime started by the removed launcher (`nylorun runtime up`, the
|
|
121
233
|
* `nylorun-runtime` bin) shares host.json; refuse to fight it.
|
|
@@ -154,37 +266,46 @@ function isOlder(version, than) {
|
|
|
154
266
|
SEMVER.test(than) &&
|
|
155
267
|
compareVersions(version, than) < 0);
|
|
156
268
|
}
|
|
157
|
-
/** The version of this Host's Runtime when it answers on the stack's port. */
|
|
158
|
-
async function runningRuntimeVersion(ctx, hostId) {
|
|
159
|
-
const persisted = await readStackEnv(ctx.paths);
|
|
160
|
-
if (!persisted?.runtimePort || typeof hostId !== "string")
|
|
161
|
-
return undefined;
|
|
162
|
-
const health = await fetchHealth(ctx.deps, `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}`);
|
|
163
|
-
return health?.status === "ok" && health.hostId === hostId ? health.version : undefined;
|
|
164
|
-
}
|
|
165
269
|
/**
|
|
166
|
-
* Refuse
|
|
167
|
-
* `runtimeVersion`)
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* the image.
|
|
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.
|
|
171
274
|
*/
|
|
172
275
|
async function refuseDowngrade(ctx, allowDowngrade) {
|
|
173
276
|
const pinned = ctx.deps.runtimeVersion;
|
|
174
277
|
const host = await readHostConfig(ctx.paths);
|
|
175
|
-
|
|
176
|
-
if (isOlder(pinned, host?.runtimeVersion))
|
|
177
|
-
newer.push(`the Host last ran Runtime ${host.runtimeVersion} (${ctx.paths.config})`);
|
|
178
|
-
const running = await runningRuntimeVersion(ctx, host?.hostId);
|
|
179
|
-
if (isOlder(pinned, running) && running !== host?.runtimeVersion)
|
|
180
|
-
newer.push(`Runtime ${running} is running`);
|
|
181
|
-
if (newer.length === 0)
|
|
278
|
+
if (!isOlder(pinned, host?.runtimeVersion))
|
|
182
279
|
return;
|
|
183
280
|
if (allowDowngrade) {
|
|
184
|
-
ctx.deps.err(`Warning:
|
|
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).`);
|
|
185
282
|
return;
|
|
186
283
|
}
|
|
187
|
-
throw new CliError(`Refusing to
|
|
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;
|
|
188
309
|
}
|
|
189
310
|
async function bringUp(ctx, options) {
|
|
190
311
|
const { deps } = ctx;
|
|
@@ -194,6 +315,8 @@ async function bringUp(ctx, options) {
|
|
|
194
315
|
await refuseDowngrade(ctx, options.allowDowngrade ?? false);
|
|
195
316
|
const prepared = await prepareStack({
|
|
196
317
|
paths: ctx.paths,
|
|
318
|
+
name: ctx.name,
|
|
319
|
+
project: ctx.project,
|
|
197
320
|
images: stackImages(deps.env, {
|
|
198
321
|
runtime: deps.runtimeVersion,
|
|
199
322
|
studio: deps.studioVersion,
|
|
@@ -203,14 +326,28 @@ async function bringUp(ctx, options) {
|
|
|
203
326
|
// An overriding image's version is unknown: keep the recorded one.
|
|
204
327
|
runtimeVersion: overridden ? undefined : deps.runtimeVersion,
|
|
205
328
|
ports: deps.ports,
|
|
329
|
+
reserved: await reservedPorts(ctx),
|
|
330
|
+
...(deps.env.NYLORUN_DERIVED_PRINCIPALS?.trim()
|
|
331
|
+
? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
|
|
332
|
+
: {}),
|
|
206
333
|
...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
|
|
207
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 } : {}) });
|
|
208
339
|
if (prepared.firstRun)
|
|
209
|
-
deps.err(`
|
|
210
|
-
const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
|
|
211
|
-
if (up !== 0)
|
|
212
|
-
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}).`);
|
|
213
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
|
+
}
|
|
214
351
|
await waitForHealth(ctx, runtimeUrl, prepared.host.hostId);
|
|
215
352
|
let studioStarted = false;
|
|
216
353
|
if (options.studio) {
|
|
@@ -221,6 +358,7 @@ async function bringUp(ctx, options) {
|
|
|
221
358
|
}
|
|
222
359
|
return {
|
|
223
360
|
runtimeUrl,
|
|
361
|
+
adminUrl,
|
|
224
362
|
hostId: prepared.host.hostId,
|
|
225
363
|
studioPort: prepared.env.studioPort,
|
|
226
364
|
studioStarted,
|
|
@@ -258,17 +396,88 @@ async function openLogin(ctx, login) {
|
|
|
258
396
|
if (!(await ctx.deps.openBrowser(login)))
|
|
259
397
|
ctx.deps.out(`Sign in ${login}`);
|
|
260
398
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
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) {
|
|
265
461
|
const flags = parseStackFlags(args, {
|
|
266
|
-
booleans: [
|
|
462
|
+
booleans: [
|
|
463
|
+
"--no-studio",
|
|
464
|
+
"--no-open",
|
|
465
|
+
"--no-link",
|
|
466
|
+
"--allow-downgrade",
|
|
467
|
+
"--studio-embed-origin-reset",
|
|
468
|
+
],
|
|
469
|
+
values: ["--name"],
|
|
267
470
|
lists: ["--studio-embed-origin"],
|
|
268
471
|
}, START_USAGE);
|
|
269
472
|
if (flags.rest.length)
|
|
270
473
|
throw usageError(`Usage: ${START_USAGE}`);
|
|
271
|
-
await
|
|
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);
|
|
272
481
|
const embedOrigins = flags.lists.get("--studio-embed-origin") ?? [];
|
|
273
482
|
const resetEmbed = flags.booleans.has("--studio-embed-origin-reset");
|
|
274
483
|
const started = await bringUp(ctx, {
|
|
@@ -278,62 +487,48 @@ async function start(ctx, args) {
|
|
|
278
487
|
? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
|
|
279
488
|
: {}),
|
|
280
489
|
});
|
|
281
|
-
ctx.
|
|
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}`);
|
|
282
496
|
if (started.studioStarted) {
|
|
283
|
-
|
|
284
|
-
if (opensBrowser(
|
|
497
|
+
deps.out(`Studio ${studioOrigin(started.studioPort)}`);
|
|
498
|
+
if (opensBrowser(deps, flags)) {
|
|
285
499
|
const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
|
|
286
500
|
if (login)
|
|
287
|
-
await openLogin(ctx, login);
|
|
501
|
+
await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
|
|
288
502
|
}
|
|
289
503
|
else
|
|
290
|
-
|
|
504
|
+
deps.err(STUDIO_SIGN_IN_HINT);
|
|
291
505
|
}
|
|
292
|
-
if ((await adminTenantCount(ctx.deps, started.runtimeUrl, started.adminKey)) === 0)
|
|
293
|
-
ctx.deps.err(TENANT_HINT);
|
|
294
506
|
return 0;
|
|
295
507
|
}
|
|
296
|
-
async function stop(
|
|
297
|
-
|
|
298
|
-
|
|
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));
|
|
299
514
|
requireStackFiles(ctx);
|
|
300
|
-
await dockerPreflight(
|
|
301
|
-
const code = await
|
|
515
|
+
await dockerPreflight(deps.docker);
|
|
516
|
+
const code = await deps.docker.stream(composeArgs(ctx, "stop"));
|
|
302
517
|
if (code !== 0)
|
|
303
518
|
throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
|
|
304
|
-
|
|
519
|
+
deps.out(`Stopped stack ${ctx.name}; volumes are kept.`);
|
|
305
520
|
return 0;
|
|
306
521
|
}
|
|
307
|
-
async function adminTenantCount(deps, runtimeUrl, adminKey) {
|
|
308
|
-
if (!adminKey)
|
|
309
|
-
return undefined;
|
|
310
|
-
try {
|
|
311
|
-
const response = await deps.fetch(`${runtimeUrl}/v1/admin/status`, {
|
|
312
|
-
headers: {
|
|
313
|
-
authorization: `Bearer ${adminKey}`,
|
|
314
|
-
[PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
|
|
315
|
-
accept: "application/json",
|
|
316
|
-
},
|
|
317
|
-
signal: AbortSignal.timeout(3000),
|
|
318
|
-
redirect: "error",
|
|
319
|
-
});
|
|
320
|
-
if (!response.ok)
|
|
321
|
-
return undefined;
|
|
322
|
-
const body = (await response.json());
|
|
323
|
-
return Array.isArray(body.tenants) ? body.tenants.length : undefined;
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
return undefined;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
522
|
async function stackStatus(ctx) {
|
|
330
523
|
const base = {
|
|
524
|
+
name: ctx.name,
|
|
331
525
|
project: ctx.project,
|
|
332
526
|
home: ctx.paths.root,
|
|
333
527
|
state: "absent",
|
|
334
528
|
runtime: { healthy: false },
|
|
335
529
|
studio: { state: "absent" },
|
|
336
530
|
restate: {},
|
|
531
|
+
gateway: { state: "absent", healthy: false },
|
|
337
532
|
services: [],
|
|
338
533
|
};
|
|
339
534
|
if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
|
|
@@ -351,10 +546,11 @@ async function stackStatus(ctx) {
|
|
|
351
546
|
const adminUrl = persisted.adminPort
|
|
352
547
|
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
353
548
|
: runtimeUrl;
|
|
354
|
-
const
|
|
355
|
-
? await
|
|
549
|
+
const tenant = healthy && adminUrl
|
|
550
|
+
? await fetchTenant(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
|
|
356
551
|
: undefined;
|
|
357
552
|
const studio = services.find((s) => s.service === "studio");
|
|
553
|
+
const gateway = services.find((s) => s.service === "gateway");
|
|
358
554
|
return {
|
|
359
555
|
...base,
|
|
360
556
|
state: services.some((s) => s.state === "running") ? "running" : "stopped",
|
|
@@ -364,8 +560,8 @@ async function stackStatus(ctx) {
|
|
|
364
560
|
healthy,
|
|
365
561
|
...(health?.version ? { version: health.version } : {}),
|
|
366
562
|
...(health?.hostId ? { hostId: health.hostId } : {}),
|
|
367
|
-
...(tenants !== undefined ? { tenants } : {}),
|
|
368
563
|
},
|
|
564
|
+
...(tenant ? { tenant } : {}),
|
|
369
565
|
studio: {
|
|
370
566
|
...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
|
|
371
567
|
state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
|
|
@@ -376,29 +572,45 @@ async function stackStatus(ctx) {
|
|
|
376
572
|
restate: persisted.restatePort
|
|
377
573
|
? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}` }
|
|
378
574
|
: {},
|
|
575
|
+
gateway: {
|
|
576
|
+
state: gateway ? [gateway.state, gateway.health].filter(Boolean).join(", ") : "absent",
|
|
577
|
+
healthy: isUp(services, "gateway"),
|
|
578
|
+
},
|
|
379
579
|
services,
|
|
380
580
|
};
|
|
381
581
|
}
|
|
382
582
|
/** What `nylorun status` reports, as data (`nylorun doctor`). */
|
|
383
|
-
export async function readStackStatus(deps) {
|
|
384
|
-
return await stackStatus(
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
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);
|
|
388
597
|
if (flags.rest.length)
|
|
389
|
-
throw usageError(
|
|
390
|
-
const result = await stackStatus(
|
|
391
|
-
const { out } =
|
|
598
|
+
throw usageError(`Usage: ${usage}`);
|
|
599
|
+
const result = await stackStatus(await selectStack(deps, nameOption(flags)));
|
|
600
|
+
const { out } = deps;
|
|
392
601
|
if (flags.booleans.has("--json")) {
|
|
393
602
|
out(JSON.stringify(result, null, 2));
|
|
394
603
|
}
|
|
395
604
|
else if (result.state === "absent") {
|
|
396
|
-
out(`Stack absent (
|
|
605
|
+
out(`Stack ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
|
|
397
606
|
}
|
|
398
607
|
else {
|
|
399
|
-
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)}`);
|
|
400
612
|
const runtimeDetail = result.runtime.healthy
|
|
401
|
-
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}
|
|
613
|
+
? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
|
|
402
614
|
: "not answering";
|
|
403
615
|
out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
|
|
404
616
|
if (result.runtime.adminUrl)
|
|
@@ -406,6 +618,7 @@ async function status(ctx, args) {
|
|
|
406
618
|
out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
|
|
407
619
|
if (result.studio.embedOrigins?.length)
|
|
408
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)`);
|
|
409
622
|
if (result.restate.url)
|
|
410
623
|
out(`Restate UI ${result.restate.url}`);
|
|
411
624
|
out(`Services ${STACK_SERVICES.map((name) => {
|
|
@@ -415,13 +628,12 @@ async function status(ctx, args) {
|
|
|
415
628
|
: "absent";
|
|
416
629
|
return `${name} ${state}`;
|
|
417
630
|
}).join(", ")}`);
|
|
418
|
-
out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
|
|
419
631
|
}
|
|
420
632
|
return result.runtime.healthy ? 0 : 3;
|
|
421
633
|
}
|
|
422
|
-
async function logs(
|
|
423
|
-
const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [-f] [--tail <n>]`;
|
|
424
|
-
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);
|
|
425
637
|
if (flags.rest.length > 1)
|
|
426
638
|
throw usageError(`Usage: ${usage}`);
|
|
427
639
|
const service = flags.rest[0];
|
|
@@ -430,19 +642,22 @@ async function logs(ctx, args) {
|
|
|
430
642
|
const tail = flags.values.get("--tail");
|
|
431
643
|
if (tail !== undefined && !/^\d+$/.test(tail))
|
|
432
644
|
throw usageError(`Invalid --tail: ${tail}`);
|
|
645
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
433
646
|
requireStackFiles(ctx);
|
|
434
|
-
await dockerPreflight(
|
|
435
|
-
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] : [])));
|
|
436
649
|
}
|
|
437
|
-
async function reset(
|
|
438
|
-
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);
|
|
439
653
|
if (flags.rest.length)
|
|
440
|
-
throw usageError(
|
|
441
|
-
const
|
|
654
|
+
throw usageError(`Usage: ${usage}`);
|
|
655
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
656
|
+
const { paths } = ctx;
|
|
442
657
|
if (!flags.booleans.has("--yes")) {
|
|
443
|
-
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] `;
|
|
444
659
|
if (!deps.confirm)
|
|
445
|
-
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.`);
|
|
446
661
|
if (!(await deps.confirm(question))) {
|
|
447
662
|
deps.err("Reset cancelled.");
|
|
448
663
|
return 1;
|
|
@@ -454,9 +669,145 @@ async function reset(ctx, args) {
|
|
|
454
669
|
if (code !== 0)
|
|
455
670
|
throw new CliError(`docker compose down failed (exit ${code}).`, 1);
|
|
456
671
|
}
|
|
457
|
-
await rm(paths.
|
|
458
|
-
await mkdir(paths.
|
|
459
|
-
|
|
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.`);
|
|
460
811
|
return 0;
|
|
461
812
|
}
|
|
462
813
|
/** The stack when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
|
|
@@ -471,7 +822,7 @@ async function runningStack(ctx, options) {
|
|
|
471
822
|
if (typeof host?.hostId !== "string")
|
|
472
823
|
return undefined;
|
|
473
824
|
const services = await composePs(ctx);
|
|
474
|
-
if (!isUp(services, "runtime"))
|
|
825
|
+
if (!isUp(services, "runtime") || !isUp(services, "gateway"))
|
|
475
826
|
return undefined;
|
|
476
827
|
const studioUp = isUp(services, "studio");
|
|
477
828
|
if (options.studio && !studioUp)
|
|
@@ -481,8 +832,12 @@ async function runningStack(ctx, options) {
|
|
|
481
832
|
if (health?.status !== "ok" || health.hostId !== host.hostId)
|
|
482
833
|
return undefined;
|
|
483
834
|
return {
|
|
835
|
+
name: ctx.name,
|
|
484
836
|
home: ctx.paths.root,
|
|
485
837
|
runtimeUrl,
|
|
838
|
+
adminUrl: persisted.adminPort
|
|
839
|
+
? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
|
|
840
|
+
: runtimeUrl,
|
|
486
841
|
hostId: host.hostId,
|
|
487
842
|
adminKey,
|
|
488
843
|
studioPort: persisted.studioPort,
|
|
@@ -490,22 +845,18 @@ async function runningStack(ctx, options) {
|
|
|
490
845
|
started: false,
|
|
491
846
|
};
|
|
492
847
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
* `start` code path without its own output. Compose progress
|
|
496
|
-
* still streams, since the first run pulls images.
|
|
497
|
-
*/
|
|
498
|
-
export async function ensureStack(deps, options) {
|
|
499
|
-
const ctx = context(deps);
|
|
500
|
-
await dockerPreflight(deps.docker);
|
|
848
|
+
async function ensureSelected(ctx, options) {
|
|
849
|
+
await dockerPreflight(ctx.deps.docker);
|
|
501
850
|
const running = await runningStack(ctx, options);
|
|
502
851
|
if (running)
|
|
503
852
|
return running;
|
|
504
853
|
const runtimeWasUp = isUp(existsSync(ctx.paths.compose) ? await composePs(ctx) : [], "runtime");
|
|
505
854
|
const started = await bringUp(ctx, options);
|
|
506
855
|
return {
|
|
856
|
+
name: ctx.name,
|
|
507
857
|
home: ctx.paths.root,
|
|
508
858
|
runtimeUrl: started.runtimeUrl,
|
|
859
|
+
adminUrl: started.adminUrl,
|
|
509
860
|
hostId: started.hostId,
|
|
510
861
|
adminKey: started.adminKey,
|
|
511
862
|
studioPort: started.studioPort,
|
|
@@ -513,6 +864,14 @@ export async function ensureStack(deps, options) {
|
|
|
513
864
|
started: !runtimeWasUp,
|
|
514
865
|
};
|
|
515
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
|
+
}
|
|
516
875
|
/** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
|
|
517
876
|
export function withNext(loginUrl, next) {
|
|
518
877
|
if (!next)
|
|
@@ -521,7 +880,7 @@ export function withNext(loginUrl, next) {
|
|
|
521
880
|
url.searchParams.set("next", next);
|
|
522
881
|
return url.toString();
|
|
523
882
|
}
|
|
524
|
-
/** The Studio page
|
|
883
|
+
/** The Studio page of the stack's Tenant, as a login `next` path. */
|
|
525
884
|
export function tenantStudioPath(tenantId) {
|
|
526
885
|
return `/tenants/${encodeURIComponent(tenantId)}`;
|
|
527
886
|
}
|
|
@@ -530,33 +889,44 @@ export function tenantStudioPath(tenantId) {
|
|
|
530
889
|
* Undefined (after a warning) when Studio does not answer.
|
|
531
890
|
*/
|
|
532
891
|
export async function studioLoginUrl(deps, stack, next) {
|
|
533
|
-
const login = await tryStudioLogin(
|
|
892
|
+
const login = await tryStudioLogin({ deps }, stack.studioPort, stack.adminKey);
|
|
534
893
|
return login === undefined ? undefined : withNext(login, next);
|
|
535
894
|
}
|
|
536
|
-
async function studio(
|
|
537
|
-
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);
|
|
538
898
|
if (flags.rest.length)
|
|
539
|
-
throw usageError(
|
|
540
|
-
const
|
|
899
|
+
throw usageError(`Usage: ${usage}`);
|
|
900
|
+
const ctx = await selectStack(deps, nameOption(flags));
|
|
901
|
+
const stack = await ensureSelected(ctx, { studio: true });
|
|
541
902
|
if (stack.started)
|
|
542
|
-
|
|
903
|
+
deps.out(`Runtime ${stack.runtimeUrl}`);
|
|
543
904
|
if (!stack.studioUp)
|
|
544
905
|
throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
|
|
545
|
-
|
|
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);
|
|
546
916
|
if (!login)
|
|
547
917
|
return 1;
|
|
548
918
|
if (flags.booleans.has("--no-open")) {
|
|
549
|
-
|
|
919
|
+
deps.out(`Studio ${login}`);
|
|
550
920
|
return 0;
|
|
551
921
|
}
|
|
552
922
|
const origin = studioOrigin(stack.studioPort);
|
|
553
|
-
|
|
554
|
-
await openLogin(
|
|
923
|
+
deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
|
|
924
|
+
await openLogin({ deps }, login);
|
|
555
925
|
return 0;
|
|
556
926
|
}
|
|
557
|
-
/** `nylorun studio`, landing on `next`
|
|
927
|
+
/** `nylorun studio`, landing on `next` when given, else on the stack's Tenant. */
|
|
558
928
|
export async function runStudioCommand(args, deps, options = {}) {
|
|
559
|
-
return await studio(
|
|
929
|
+
return await studio(deps, args, options);
|
|
560
930
|
}
|
|
561
931
|
const COMMANDS = {
|
|
562
932
|
start,
|
|
@@ -567,15 +937,18 @@ const COMMANDS = {
|
|
|
567
937
|
status,
|
|
568
938
|
logs,
|
|
569
939
|
reset,
|
|
570
|
-
studio: (
|
|
940
|
+
studio: (deps, args) => studio(deps, args),
|
|
941
|
+
ls,
|
|
942
|
+
delete: deleteStack,
|
|
943
|
+
legacy,
|
|
571
944
|
};
|
|
572
945
|
export function isStackCommand(name) {
|
|
573
946
|
return name !== undefined && Object.hasOwn(COMMANDS, name);
|
|
574
947
|
}
|
|
575
|
-
/** 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`). */
|
|
576
949
|
export async function runStackCommand(name, args, deps) {
|
|
577
950
|
const command = COMMANDS[name];
|
|
578
951
|
if (!command)
|
|
579
952
|
throw usageError(`Unknown stack command ${name}.\n${stackUsage}`);
|
|
580
|
-
return await command(
|
|
953
|
+
return await command(deps, args);
|
|
581
954
|
}
|