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