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.
@@ -1,38 +1,90 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile, rm, mkdir } from "node:fs/promises";
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 { 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
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] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
16
- set up the stack on first run, then start it; print the Runtime and Studio URLs and open Studio signed in (in a terminal).
17
- refuses a Runtime older than the Host last ran unless --allow-downgrade.
18
- --studio-embed-origin lets one more exact origin show Studio in a frame (a desktop app's development server); kept across starts until --studio-embed-origin-reset
19
- down|stop stop the stack's containers; keep volumes
20
- status [--json] [--env] services, endpoints and Runtime health (--env: the linked Project's variables)
21
- logs [service] [-f] [--tail <n>] stack logs (${STACK_SERVICES.join(", ")})
22
- 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
23
- 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)`;
24
33
  const usageError = (message) => new CliError(message, 2);
25
- export function stackProject(env) {
26
- const project = env.NYLORUN_STACK_PROJECT?.trim() || DEFAULT_PROJECT;
27
- if (!/^[a-z0-9][a-z0-9_-]*$/.test(project))
28
- throw usageError(`NYLORUN_STACK_PROJECT must be lowercase letters, digits, "-" or "_": ${project}`);
29
- 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");
30
40
  }
31
- 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");
32
81
  return {
33
82
  deps,
34
- paths: stackPaths(resolveHome(undefined, deps.env)),
35
- project: stackProject(deps.env),
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 to start a Runtime older than the one the Host last ran (host.json
167
- * `runtimeVersion`) or the one running now: Compose would replace it, every
168
- * client on the machine shares the stack, and a Runtime older than a Tenant's
169
- * schema quarantines that Tenant. Skipped when `NYLORUN_RUNTIME_IMAGE` names
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
- const newer = [];
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: downgrading to Runtime ${pinned}: ${newer.join(" and ")}. Tenants a newer Runtime migrated are quarantined.`);
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 downgrade: this nylorun pins Runtime ${pinned}, but ${newer.join(" and ")}. Every project and app on this machine shares the stack, and a Runtime older than a Tenant's schema quarantines that Tenant. Update nylorun (npx nylorun@latest up), or run "nylorun start --allow-downgrade" to start Runtime ${pinned} anyway.`, 5);
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(`Wrote ${ctx.paths.compose} and ${ctx.paths.env} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
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
- /** Printed by `start` while the Host has no Tenant: nylorun never creates one. */
262
- export const TENANT_HINT = "No Tenant yet. Create one in Studio, or run `npx @nylorun/cli tenant create` in your project.";
263
- const START_USAGE = "nylorun start [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
264
- async function start(ctx, args) {
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: ["--no-studio", "--no-open", "--allow-downgrade", "--studio-embed-origin-reset"],
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 dockerPreflight(ctx.deps.docker);
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.deps.out(`Runtime ${started.runtimeUrl}`);
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
- ctx.deps.out(`Studio ${studioOrigin(started.studioPort)}`);
284
- if (opensBrowser(ctx.deps, flags)) {
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
- ctx.deps.err(STUDIO_SIGN_IN_HINT);
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(ctx, args) {
297
- if (args.length)
298
- 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));
299
514
  requireStackFiles(ctx);
300
- await dockerPreflight(ctx.deps.docker);
301
- 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"));
302
517
  if (code !== 0)
303
518
  throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
304
- ctx.deps.out("Stopped the Nylorun stack; volumes are kept.");
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 tenants = healthy && adminUrl
355
- ? await adminTenantCount(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
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(context(deps));
385
- }
386
- async function status(ctx, args) {
387
- 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);
388
597
  if (flags.rest.length)
389
- throw usageError("Usage: nylorun status [--json]");
390
- const result = await stackStatus(ctx);
391
- const { out } = ctx.deps;
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 (no stack under ${result.home}; run "nylorun start")`);
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 ?? "?"}${result.runtime.tenants !== undefined ? `, ${result.runtime.tenants} Tenant(s)` : ""}`
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(ctx, args) {
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(ctx.deps.docker);
435
- 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] : [])));
436
649
  }
437
- async function reset(ctx, args) {
438
- 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);
439
653
  if (flags.rest.length)
440
- throw usageError("Usage: nylorun reset [--yes]");
441
- const { deps, paths } = ctx;
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 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] `;
444
659
  if (!deps.confirm)
445
- 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.`);
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.tenants, { recursive: true, force: true });
458
- await mkdir(paths.tenants, { recursive: true, mode: 0o700 });
459
- 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.`);
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
- * Start the stack unless it is already running (`nylorun studio`): the
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 for one Tenant, as a login `next` path. */
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(context(deps), stack.studioPort, stack.adminKey);
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(ctx, args, options = {}) {
537
- 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);
538
898
  if (flags.rest.length)
539
- throw usageError("Usage: nylorun studio [--no-open]");
540
- 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 });
541
902
  if (stack.started)
542
- ctx.deps.out(`Runtime ${stack.runtimeUrl}`);
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
- 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);
546
916
  if (!login)
547
917
  return 1;
548
918
  if (flags.booleans.has("--no-open")) {
549
- ctx.deps.out(`Studio ${login}`);
919
+ deps.out(`Studio ${login}`);
550
920
  return 0;
551
921
  }
552
922
  const origin = studioOrigin(stack.studioPort);
553
- ctx.deps.out(`Studio ${options.next ? new URL(options.next, origin).toString() : origin}`);
554
- await openLogin(ctx, login);
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` (the linked Project's Tenant) when given. */
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(context(deps), args, options);
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: (ctx, args) => studio(ctx, args),
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(context(deps), args);
953
+ return await command(deps, args);
581
954
  }