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.
@@ -1,35 +1,90 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile, rm, mkdir } from "node:fs/promises";
3
- import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
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 { resolveHome } from "../home.js";
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 DEFAULT_PROJECT = "nylorun";
15
- export const stackUsage = ` up|start [--no-studio] [--no-open] set up the stack on first run, then start it; print the Runtime and Studio URLs and open Studio signed in (in a terminal)
16
- down|stop stop the stack's containers; keep volumes
17
- status [--json] [--env] services, endpoints and Runtime health (--env: the linked Project's variables)
18
- logs [service] [-f] [--tail <n>] stack logs (${STACK_SERVICES.join(", ")})
19
- studio [--no-open] sign a browser in to Studio (on the linked Project's Tenant); --no-open prints the login URL; start the stack if it is stopped
20
- reset [--yes] delete the stack's volumes and Tenant directories`;
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
- export function stackProject(env) {
23
- const project = env.NYLORUN_STACK_PROJECT?.trim() || DEFAULT_PROJECT;
24
- if (!/^[a-z0-9][a-z0-9_-]*$/.test(project))
25
- throw usageError(`NYLORUN_STACK_PROJECT must be lowercase letters, digits, "-" or "_": ${project}`);
26
- return project;
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 context(deps) {
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
- paths: stackPaths(resolveHome(undefined, deps.env)),
32
- project: stackProject(deps.env),
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
- runtimeVersion: deps.runtimeVersion,
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(`Wrote ${ctx.paths.compose} and ${ctx.paths.env} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
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
- /** Printed by `start` while the Host has no Tenant: nylorun never creates one. */
206
- export const TENANT_HINT = "No Tenant yet. Create one in Studio, or run `npx @nylorun/cli tenant create` in your project.";
207
- async function start(ctx, args) {
208
- const usage = "nylorun start [--no-studio] [--no-open]";
209
- const flags = parseStackFlags(args, { booleans: ["--no-studio", "--no-open"] }, usage);
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: ${usage}`);
212
- await dockerPreflight(ctx.deps.docker);
213
- const started = await bringUp(ctx, { studio: !flags.booleans.has("--no-studio") });
214
- ctx.deps.out(`Runtime ${started.runtimeUrl}`);
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
- ctx.deps.out(`Studio ${studioOrigin(started.studioPort)}`);
217
- if (opensBrowser(ctx.deps, flags)) {
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
- ctx.deps.err(STUDIO_SIGN_IN_HINT);
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(ctx, args) {
230
- if (args.length)
231
- throw usageError("Usage: nylorun stop");
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(ctx.deps.docker);
234
- const code = await ctx.deps.docker.stream(composeArgs(ctx, "stop"));
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
- ctx.deps.out("Stopped the Nylorun stack; volumes are kept.");
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
- const tenants = healthy && runtimeUrl
284
- ? await adminTenantCount(ctx.deps, runtimeUrl, await readAdminKey(ctx.paths))
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(context(deps));
310
- }
311
- async function status(ctx, args) {
312
- const flags = parseStackFlags(args, { booleans: ["--json"] }, "nylorun status [--json]");
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("Usage: nylorun status [--json]");
315
- const result = await stackStatus(ctx);
316
- const { out } = ctx.deps;
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 (no stack under ${result.home}; run "nylorun start")`);
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 ?? "?"}${result.runtime.tenants !== undefined ? `, ${result.runtime.tenants} Tenant(s)` : ""}`
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(ctx, args) {
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(ctx.deps.docker);
356
- return await ctx.deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
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(ctx, args) {
359
- const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, "nylorun reset [--yes]");
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("Usage: nylorun reset [--yes]");
362
- const { deps, paths } = ctx;
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 the stack's volumes (project ${ctx.project}) and every Tenant under ${paths.tenants}? This cannot be undone. [y/N] `;
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("nylorun reset deletes all Tenants; pass --yes to confirm when not in a terminal.");
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.tenants, { recursive: true, force: true });
379
- await mkdir(paths.tenants, { recursive: true, mode: 0o700 });
380
- deps.out(`Reset the Nylorun stack: volumes and Tenant directories deleted. Run "nylorun start" to start again.`);
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
- * Start the stack unless it is already running (`nylorun studio`): the
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 for one Tenant, as a login `next` path. */
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(context(deps), stack.studioPort, stack.adminKey);
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(ctx, args, options = {}) {
458
- const flags = parseStackFlags(args, { booleans: ["--no-open"] }, "nylorun studio [--no-open]");
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("Usage: nylorun studio [--no-open]");
461
- const stack = await ensureStack(ctx.deps, { studio: true });
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
- ctx.deps.out(`Runtime ${stack.runtimeUrl}`);
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
- const login = await studioLoginUrl(ctx.deps, stack, options.next);
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
- ctx.deps.out(`Studio ${login}`);
919
+ deps.out(`Studio ${login}`);
471
920
  return 0;
472
921
  }
473
922
  const origin = studioOrigin(stack.studioPort);
474
- ctx.deps.out(`Studio ${options.next ? new URL(options.next, origin).toString() : origin}`);
475
- await openLogin(ctx, login);
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` (the linked Project's Tenant) when given. */
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(context(deps), args, options);
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: (ctx, args) => studio(ctx, args),
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(context(deps), args);
953
+ return await command(deps, args);
502
954
  }