nylorun 0.4.0-beta → 0.6.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 (42) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +207 -105
  3. package/dist/cli.js +24 -22
  4. package/dist/doctor.d.ts +10 -8
  5. package/dist/doctor.js +54 -37
  6. package/dist/home.d.ts +1 -1
  7. package/dist/home.js +1 -1
  8. package/dist/project/derived-key.d.ts +2 -2
  9. package/dist/project/derived-key.js +2 -2
  10. package/dist/project/link.d.ts +7 -7
  11. package/dist/project/link.js +3 -3
  12. package/dist/stack/commands.d.ts +23 -15
  13. package/dist/stack/commands.js +332 -214
  14. package/dist/stack/compose-file.d.ts +11 -8
  15. package/dist/stack/compose-file.js +54 -21
  16. package/dist/stack/docker.d.ts +10 -0
  17. package/dist/stack/docker.js +64 -1
  18. package/dist/stack/env-file.d.ts +12 -2
  19. package/dist/stack/env-file.js +13 -3
  20. package/dist/stack/host-files.d.ts +4 -4
  21. package/dist/stack/host-files.js +3 -3
  22. package/dist/stack/images.d.ts +4 -2
  23. package/dist/stack/images.js +3 -2
  24. package/dist/stack/index.d.ts +4 -3
  25. package/dist/stack/index.js +10 -4
  26. package/dist/stack/paths.d.ts +3 -3
  27. package/dist/stack/paths.js +1 -1
  28. package/dist/stack/ports.d.ts +4 -2
  29. package/dist/stack/ports.js +3 -1
  30. package/dist/stack/prepare.d.ts +7 -3
  31. package/dist/stack/prepare.js +5 -3
  32. package/dist/stack/proxy.d.ts +78 -0
  33. package/dist/stack/proxy.js +224 -0
  34. package/dist/stack/stacks.d.ts +37 -34
  35. package/dist/stack/stacks.js +91 -57
  36. package/dist/stack/studio-login.d.ts +4 -3
  37. package/dist/stack/studio-login.js +5 -4
  38. package/dist/stack/versions.d.ts +1 -1
  39. package/dist/stack/versions.js +1 -1
  40. package/dist/telemetry.d.ts +31 -0
  41. package/dist/telemetry.js +79 -0
  42. package/package.json +5 -5
@@ -1,83 +1,137 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { basename, join, resolve } from "node:path";
2
+ import { mkdir, readFile, rm } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
4
  import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
5
5
  import { CliError } from "../errors.js";
6
6
  import { deriveTenantKey, PROJECT_PRINCIPAL_ID } from "../project/derived-key.js";
7
7
  import { readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
8
8
  import { findProjectRoot } from "../project/root.js";
9
9
  import { seedTenant } from "../project/seed.js";
10
- import { dockerPreflight, parseComposePs, } from "./docker.js";
10
+ import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
11
+ import { dockerPreflight, formatBytes, parseComposePs, tenantMemory, } from "./docker.js";
11
12
  import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
12
13
  import { runtimeImageOverridden, stackImages } from "./images.js";
13
14
  import { stackPaths } from "./paths.js";
14
15
  import { prepareStack, readStackEnv } from "./prepare.js";
15
- import { assertStackName, chooseStackName, defaultNylorunRoot, LEGACY_ENTRIES, legacyStack, listStacks, readStackRecord, sanitizeStackName, stackRoot, stacksDir, writeStackRecord, } from "./stacks.js";
16
+ import { disconnectProxy, ensureProxySettings, PROXY_PROJECT, proxyDisabled, proxyOrigin, readProxySettings, readProxyStatus, refreshProxyRoutes, startProxy, stopProxy, } from "./proxy.js";
17
+ import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
16
18
  import { mintStudioLogin, studioOrigin } from "./studio-login.js";
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)`;
19
+ export const STACK_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
20
+ const CORE_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime"];
21
+ export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
22
+ start the project's Tenant, creating it and the Project link (.nylorun/link.json,
23
+ credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
24
+ signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
25
+ starts the default Tenant (or --tenant's) without linking the current directory
26
+ down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's, and the Studio proxy); keep volumes
27
+ status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
28
+ logs [service] [--tenant <name>] [-f] [--tail <n>]
29
+ container logs (${STACK_SERVICES.join(", ")})
30
+ studio [--tenant <name>] [--no-open]
31
+ sign a browser in to Studio on the Tenant; --no-open prints the login URL; start the Tenant if it is stopped
32
+ reset [--tenant <name>] [--yes] delete the Tenant's data (volumes, tenant/ and vault key); the next start creates it anew
33
+ ls [--json] the Tenants on this machine, with their memory in use
34
+ delete <tenant> --yes remove a Tenant: containers, volumes and its Host root, with the vault key (KEK)`;
33
35
  const usageError = (message) => new CliError(message, 2);
34
- /** The Compose project of stack `name`: `nylorun-<name>`, or `NYLORUN_STACK_PROJECT`. */
36
+ /** The Compose project of Tenant `name`: `nylorun-<name>`, or `NYLORUN_COMPOSE_PROJECT`. */
35
37
  export function stackProject(env, name) {
36
- const override = env.NYLORUN_STACK_PROJECT?.trim();
38
+ const override = env.NYLORUN_COMPOSE_PROJECT?.trim();
37
39
  if (!override)
38
40
  return `nylorun-${name}`;
39
- return assertStackName(override, "NYLORUN_STACK_PROJECT");
41
+ return assertTenantName(override, "NYLORUN_COMPOSE_PROJECT");
40
42
  }
41
43
  function nylorunRoot(deps) {
42
44
  return deps.nylorunRoot ?? defaultNylorunRoot();
43
45
  }
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(", ")}.` : "";
46
+ /**
47
+ * The Studio proxy serves the machine's Tenants (`~/.nylorun/tenants`), unless
48
+ * NYLORUN_PROXY_DISABLED=1; a Host root or Compose project of its own (NYLORUN_HOME,
49
+ * NYLORUN_COMPOSE_PROJECT, as the repository's smokes use) is outside it.
50
+ */
51
+ function proxied(ctx) {
52
+ const { env } = ctx.deps;
53
+ return !proxyDisabled(env) && !env.NYLORUN_HOME?.trim() && !env.NYLORUN_COMPOSE_PROJECT?.trim();
54
+ }
55
+ /** Studio's URL for browsers: through the proxy when it serves the Tenant, else its own port. */
56
+ async function studioUrl(ctx, studioPort) {
57
+ const settings = proxied(ctx) ? await readProxySettings(nylorunRoot(ctx.deps)) : undefined;
58
+ return settings ? proxyOrigin(ctx.name, settings.port) : studioOrigin(studioPort);
59
+ }
60
+ /** The proxy's routes: every Tenant on this machine. */
61
+ async function proxyRoutes(deps) {
62
+ return (await listTenants(nylorunRoot(deps))).map((tenant) => ({
63
+ name: tenant.name,
64
+ project: stackProject(deps.env, tenant.name),
65
+ }));
66
+ }
67
+ /**
68
+ * Compose's names for a 0.5 Tenant's volumes (`<project>_postgres`, …) that exist on this
69
+ * engine, and whether this release's `<project>-postgres` does.
70
+ */
71
+ async function oldVolumes(docker, project) {
72
+ const result = await docker.run(["volume", "ls", "--quiet", "--filter", `name=${project}`]);
73
+ const names = new Set(result.code === 0 ? result.stdout.split(/\s+/) : []);
74
+ return {
75
+ old: ["postgres", "restate", "s2", "workspaces"]
76
+ .map((volume) => `${project}_${volume}`)
77
+ .filter((name) => names.has(name)),
78
+ current: names.has(`${project}-postgres`),
79
+ };
80
+ }
81
+ /**
82
+ * Before `docker compose down` (`reset`, `delete`): detach the proxy from the Tenant's network
83
+ * so Compose can remove it. After it: drop a 0.5 Tenant's volumes and network.
84
+ */
85
+ async function composeDown(ctx, args) {
86
+ const { docker } = ctx.deps;
87
+ await disconnectProxy(docker, ctx.project);
88
+ const code = await docker.stream(args);
89
+ if (code !== 0)
90
+ throw new CliError(`docker compose down failed (exit ${code}).`, 1);
91
+ const { old } = await oldVolumes(docker, ctx.project);
92
+ if (old.length) {
93
+ await docker.run(["volume", "rm", ...old]);
94
+ await docker.run(["network", "rm", `${ctx.project}_default`]);
95
+ }
96
+ }
97
+ async function tenantNames(deps) {
98
+ const names = (await listTenants(nylorunRoot(deps))).map((tenant) => tenant.name);
99
+ return names.length ? ` Tenants on this machine: ${names.join(", ")}.` : "";
47
100
  }
48
101
  /**
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.
102
+ * Which Tenant a command acts on: `--tenant`, then `NYLORUN_TENANT`, then the Tenant of the
103
+ * project's link (format 3; `start` replaces an older one); for `start` in a project, then a
104
+ * name from the project directory (`chooseTenantName`); outside a project (or with
105
+ * --no-link), `default`. `NYLORUN_HOME` sets the Host root whatever the name; its name
106
+ * otherwise comes from its `tenant.json`, the project directory or the Host root's own.
53
107
  */
54
108
  async function selectStack(deps, options = {}) {
55
109
  const { env } = deps;
56
110
  const base = nylorunRoot(deps);
57
111
  const projectDir = options.noLink ? undefined : findProjectRoot(deps.cwd ?? process.cwd());
58
112
  const link = projectDir ? await readProjectLink(projectDir) : undefined;
59
- const explicit = options.name ?? (env.NYLORUN_STACK?.trim() || undefined);
113
+ const explicit = options.name ?? (env.NYLORUN_TENANT?.trim() || undefined);
60
114
  if (explicit !== undefined)
61
- assertStackName(explicit, options.name !== undefined ? "--name" : "NYLORUN_STACK");
62
- let name = explicit ?? (link?.format === 2 ? link.stack : undefined);
115
+ assertTenantName(explicit, options.name !== undefined ? "--tenant" : "NYLORUN_TENANT");
116
+ let name = explicit ?? link?.tenant;
63
117
  let root;
64
118
  const home = env.NYLORUN_HOME?.trim();
65
119
  if (home) {
66
120
  root = resolve(home);
67
121
  name ??=
68
- (await readStackRecord(root))?.name ??
69
- sanitizeStackName(basename(projectDir ?? root));
122
+ (await readTenantRecord(root))?.name ??
123
+ sanitizeTenantName(basename(projectDir ?? root));
70
124
  }
71
125
  else {
72
126
  if (name === undefined && options.start && projectDir)
73
- name = await chooseStackName(base, projectDir);
127
+ name = await chooseTenantName(base, projectDir);
128
+ if (name === undefined && !projectDir)
129
+ name = DEFAULT_TENANT;
74
130
  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);
131
+ throw usageError(`No Tenant selected: run "nylorun start" in this project, pass --tenant <name>, or set NYLORUN_TENANT.${await tenantNames(deps)}`);
132
+ root = tenantRoot(base, name);
79
133
  }
80
- assertStackName(name, "The stack name");
134
+ assertTenantName(name, "The Tenant name");
81
135
  return {
82
136
  deps,
83
137
  name,
@@ -101,7 +155,7 @@ function composeArgs(ctx, ...args) {
101
155
  }
102
156
  function requireStackFiles(ctx) {
103
157
  if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
104
- throw new CliError(`No Nylorun stack ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
158
+ throw new CliError(`No Tenant ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
105
159
  }
106
160
  export function parseStackFlags(args, allowed, usage) {
107
161
  const booleans = new Set();
@@ -137,9 +191,9 @@ export function parseStackFlags(args, allowed, usage) {
137
191
  }
138
192
  return { rest, booleans, values, lists };
139
193
  }
140
- /** `--name` when given. */
194
+ /** `--tenant` when given. */
141
195
  function nameOption(flags) {
142
- const name = flags.values.get("--name");
196
+ const name = flags.values.get("--tenant");
143
197
  return name === undefined ? {} : { name };
144
198
  }
145
199
  async function sleep(ms) {
@@ -203,16 +257,16 @@ async function fetchTenant(deps, adminUrl, adminKey) {
203
257
  return undefined;
204
258
  }
205
259
  }
206
- /** Why the stack's Tenant is unavailable, with the repair the Runtime names. */
260
+ /** Why the Tenant is unavailable, with the repair the Runtime names. */
207
261
  function tenantCauseMessage(ctx, tenant) {
208
262
  const cause = tenant.cause;
209
263
  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).`
264
+ ? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the Tenant's database: update nylorun (npx nylorun@latest start).`
211
265
  : "";
212
- return `The Tenant of stack ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
266
+ return `Tenant ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
213
267
  }
214
268
  /**
215
- * Wait until the stack's Tenant is open: the Runtime creates it on the first start and opens
269
+ * Wait until the Tenant is open: the Runtime creates it on the first start and opens
216
270
  * it on later ones. A Tenant that could not be opened has a cause; report it.
217
271
  */
218
272
  async function waitForTenant(ctx, adminUrl, adminKey) {
@@ -224,7 +278,7 @@ async function waitForTenant(ctx, adminUrl, adminKey) {
224
278
  if (tenant?.cause)
225
279
  throw new CliError(tenantCauseMessage(ctx, tenant), 7);
226
280
  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);
281
+ 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);
228
282
  await sleep(ctx.deps.pollMs ?? 500);
229
283
  }
230
284
  }
@@ -267,7 +321,7 @@ function isOlder(version, than) {
267
321
  compareVersions(version, than) < 0);
268
322
  }
269
323
  /**
270
- * Refuse a Runtime older than the stack's database: the Runtime that last ran the stack
324
+ * Refuse a Runtime older than the Tenant's database: the Runtime that last ran the Tenant
271
325
  * (host.json `runtimeVersion`) migrated it, and an older one cannot open its Tenant
272
326
  * (`schema-too-new`). Checked before Compose replaces anything. Skipped when
273
327
  * `NYLORUN_RUNTIME_IMAGE` names the image.
@@ -278,21 +332,16 @@ async function refuseDowngrade(ctx, allowDowngrade) {
278
332
  if (!isOlder(pinned, host?.runtimeVersion))
279
333
  return;
280
334
  if (allowDowngrade) {
281
- ctx.deps.err(`Warning: starting Runtime ${pinned} on stack ${ctx.name}, which Runtime ${host.runtimeVersion} last ran. If that Runtime migrated the database, the Tenant stays unavailable (schema-too-new).`);
335
+ 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).`);
282
336
  return;
283
337
  }
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);
338
+ 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);
285
339
  }
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);
340
+ /** Ports the Tenants on this machine keep in their `.env` (but the one under `except`). */
341
+ async function tenantPorts(base, except) {
342
+ const others = (await listTenants(base))
343
+ .filter((tenant) => resolve(tenant.root) !== except)
344
+ .map((tenant) => stackPaths(tenant.root));
296
345
  const reserved = new Set();
297
346
  for (const paths of others) {
298
347
  const persisted = await readStackEnv(paths);
@@ -307,12 +356,31 @@ async function reservedPorts(ctx) {
307
356
  }
308
357
  return reserved;
309
358
  }
359
+ /** Ports other Tenants and the proxy keep, so a new Tenant avoids them. */
360
+ async function reservedPorts(ctx) {
361
+ const base = nylorunRoot(ctx.deps);
362
+ const reserved = await tenantPorts(base, ctx.paths.root);
363
+ const proxy = await readProxySettings(base);
364
+ if (proxy)
365
+ reserved.add(proxy.port);
366
+ return reserved;
367
+ }
310
368
  async function bringUp(ctx, options) {
311
369
  const { deps } = ctx;
312
370
  await refuseLauncherRuntime(ctx);
313
371
  const overridden = runtimeImageOverridden(deps.env);
314
372
  if (!overridden)
315
373
  await refuseDowngrade(ctx, options.allowDowngrade ?? false);
374
+ const legacy = await oldVolumes(deps.docker, ctx.project);
375
+ if (legacy.old.length && !legacy.current)
376
+ throw new CliError(`Tenant ${ctx.name} was created by nylorun 0.5; its data is in the old volumes ${legacy.old.join(", ")}. Run "nylorun reset --tenant ${ctx.name}" to start it fresh; that also removes the old volumes (or drop them yourself with "docker volume rm ${legacy.old.join(" ")}").`, 3);
377
+ const base = nylorunRoot(deps);
378
+ const telemetry = await readTelemetry(base);
379
+ const analytics = telemetryDecision(deps.env, telemetry).enabled;
380
+ // The proxy's port avoids every Tenant's; a new Tenant's ports then avoid the proxy's.
381
+ const proxy = proxied(ctx)
382
+ ? await ensureProxySettings(base, deps.ports, await tenantPorts(base))
383
+ : undefined;
316
384
  const prepared = await prepareStack({
317
385
  paths: ctx.paths,
318
386
  name: ctx.name,
@@ -331,13 +399,19 @@ async function bringUp(ctx, options) {
331
399
  ? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
332
400
  : {}),
333
401
  ...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
402
+ ...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
403
+ ...(proxy ? { studioPublicOrigin: proxyOrigin(ctx.name, proxy.port) } : {}),
334
404
  });
335
- const record = await readStackRecord(ctx.paths.root);
405
+ if (analytics && options.studio && telemetry.noticeShown === undefined) {
406
+ deps.err(TELEMETRY_NOTICE);
407
+ await writeTelemetry(nylorunRoot(deps), { ...telemetry, noticeShown: new Date().toISOString() });
408
+ }
409
+ const record = await readTenantRecord(ctx.paths.root);
336
410
  const project = record?.project ?? ctx.projectDir;
337
411
  if (record?.name !== ctx.name || record.project !== project)
338
- await writeStackRecord(ctx.paths.root, { name: ctx.name, ...(project ? { project } : {}) });
412
+ await writeTenantRecord(ctx.paths.root, { name: ctx.name, ...(project ? { project } : {}) });
339
413
  if (prepared.firstRun)
340
- deps.err(`Created stack ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
414
+ deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
341
415
  const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
342
416
  const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
343
417
  const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
@@ -356,30 +430,59 @@ async function bringUp(ctx, options) {
356
430
  if (!studioStarted)
357
431
  deps.err(`Warning: Studio did not start (image ${prepared.env.studioImage}). The Runtime is up; see "nylorun logs studio".`);
358
432
  }
433
+ let studioUrl = studioOrigin(prepared.env.studioPort);
434
+ if (studioStarted && proxy) {
435
+ const up = await startProxy({
436
+ docker: deps.docker,
437
+ base,
438
+ settings: proxy,
439
+ routes: await proxyRoutes(deps),
440
+ err: deps.err,
441
+ ...(deps.pollMs !== undefined ? { pollMs: deps.pollMs } : {}),
442
+ });
443
+ if (up)
444
+ studioUrl = proxyOrigin(ctx.name, proxy.port);
445
+ }
359
446
  return {
360
447
  runtimeUrl,
361
448
  adminUrl,
362
449
  hostId: prepared.host.hostId,
363
450
  studioPort: prepared.env.studioPort,
451
+ studioUrl,
364
452
  studioStarted,
365
453
  adminKey: prepared.adminKey,
366
454
  };
367
455
  }
368
- async function tryStudioLogin(ctx, studioPort, adminKey) {
369
- const deadline = Date.now() + (ctx.deps.loginTimeoutMs ?? 15_000);
456
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
457
+ /**
458
+ * A login URL on Studio's URL: through the proxy when it serves the Tenant (once; a proxy that
459
+ * does not answer falls back, saying so), else on Studio's own port, retried while it starts.
460
+ */
461
+ async function tryStudioLogin(ctx, studio, adminKey) {
462
+ const { deps } = ctx;
463
+ const origin = studioOrigin(studio.port);
464
+ if (studio.url !== origin) {
465
+ try {
466
+ return await mintStudioLogin({ fetch: deps.fetch, origin: studio.url, adminKey });
467
+ }
468
+ catch (error) {
469
+ deps.err(`Studio did not answer through the proxy at ${studio.url} (${errorText(error)}); signing in at ${origin} instead.`);
470
+ }
471
+ }
472
+ const deadline = Date.now() + (deps.loginTimeoutMs ?? 15_000);
370
473
  let lastError = "";
371
474
  for (;;) {
372
475
  try {
373
- return await mintStudioLogin({ fetch: ctx.deps.fetch, studioPort, adminKey });
476
+ return await mintStudioLogin({ fetch: deps.fetch, origin, adminKey });
374
477
  }
375
478
  catch (error) {
376
- lastError = error instanceof Error ? error.message : String(error);
479
+ lastError = errorText(error);
377
480
  }
378
481
  if (Date.now() > deadline)
379
482
  break;
380
- await sleep(ctx.deps.pollMs ?? 500);
483
+ await sleep(deps.pollMs ?? 500);
381
484
  }
382
- ctx.deps.err(`Warning: Studio at ${studioOrigin(studioPort)} is not reachable for a login (${lastError}).`);
485
+ deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
383
486
  return undefined;
384
487
  }
385
488
  /** Printed by `start` when it does not open Studio itself. */
@@ -396,36 +499,23 @@ async function openLogin(ctx, login) {
396
499
  if (!(await ctx.deps.openBrowser(login)))
397
500
  ctx.deps.out(`Sign in ${login}`);
398
501
  }
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
502
  /**
411
- * Link the project to the stack: `.nylorun/link.json` (format 2) and
503
+ * Link the project to the Tenant: `.nylorun/link.json` (format 3) and
412
504
  * `.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`.
505
+ * rewritten only when it is older, or the Tenant's name, URL, Host or id changed; a new link
506
+ * seeds the Tenant from the project's `.env`.
415
507
  */
416
508
  async function linkProject(ctx, started, tenantId) {
417
509
  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
510
  const applicationKey = deriveTenantKey(started.adminKey, tenantId, PROJECT_PRINCIPAL_ID);
421
- const fresh = link?.format !== 2 ||
422
- link.stack !== ctx.name ||
511
+ const fresh = link?.format !== 3 ||
512
+ link.tenant !== ctx.name ||
423
513
  link.hostUrl !== started.runtimeUrl ||
424
514
  link.hostId !== started.hostId ||
425
515
  link.tenantId !== tenantId;
426
516
  if (fresh)
427
517
  await writeProjectLink(projectDir, {
428
- stack: ctx.name,
518
+ tenant: ctx.name,
429
519
  hostUrl: started.runtimeUrl,
430
520
  hostId: started.hostId,
431
521
  tenantId,
@@ -440,7 +530,7 @@ async function linkProject(ctx, started, tenantId) {
440
530
  });
441
531
  if (!fresh)
442
532
  return;
443
- deps.err(`Linked ${projectDir} to stack ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
533
+ deps.err(`Linked ${projectDir} to Tenant ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
444
534
  try {
445
535
  const seeded = await seedTenant({
446
536
  fetch: deps.fetch,
@@ -456,7 +546,7 @@ async function linkProject(ctx, started, tenantId) {
456
546
  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
547
  }
458
548
  }
459
- const START_USAGE = "nylorun start [--name <stack>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
549
+ const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]";
460
550
  async function start(deps, args) {
461
551
  const flags = parseStackFlags(args, {
462
552
  booleans: [
@@ -466,7 +556,7 @@ async function start(deps, args) {
466
556
  "--allow-downgrade",
467
557
  "--studio-embed-origin-reset",
468
558
  ],
469
- values: ["--name"],
559
+ values: ["--tenant"],
470
560
  lists: ["--studio-embed-origin"],
471
561
  }, START_USAGE);
472
562
  if (flags.rest.length)
@@ -477,7 +567,6 @@ async function start(deps, args) {
477
567
  noLink: flags.booleans.has("--no-link"),
478
568
  });
479
569
  await dockerPreflight(deps.docker);
480
- await noteLegacy(deps);
481
570
  const embedOrigins = flags.lists.get("--studio-embed-origin") ?? [];
482
571
  const resetEmbed = flags.booleans.has("--studio-embed-origin-reset");
483
572
  const started = await bringUp(ctx, {
@@ -490,35 +579,86 @@ async function start(deps, args) {
490
579
  const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
491
580
  if (ctx.projectDir)
492
581
  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})` : ""}`);
582
+ deps.out(`Tenant ${ctx.name} (${tenant.id})`);
495
583
  deps.out(`Runtime ${started.runtimeUrl}`);
496
584
  if (started.studioStarted) {
497
- deps.out(`Studio ${studioOrigin(started.studioPort)}`);
585
+ deps.out(`Studio ${started.studioUrl}`);
498
586
  if (opensBrowser(deps, flags)) {
499
- const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
587
+ const login = await tryStudioLogin(ctx, { port: started.studioPort, url: started.studioUrl }, started.adminKey);
500
588
  if (login)
501
589
  await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
502
590
  }
503
591
  else
504
592
  deps.err(STUDIO_SIGN_IN_HINT);
505
593
  }
594
+ await alsoRunning(ctx);
506
595
  return 0;
507
596
  }
597
+ /** After `start`: the machine's other running Tenants and their memory, on stderr. */
598
+ async function alsoRunning(ctx) {
599
+ const { deps } = ctx;
600
+ const projects = await composeProjects(deps.docker);
601
+ if (!projects)
602
+ return;
603
+ const others = (await listTenants(nylorunRoot(deps)))
604
+ .map((tenant) => tenant.name)
605
+ .filter((name) => name !== ctx.name && projects.get(`nylorun-${name}`) === "running");
606
+ if (others.length === 0)
607
+ return;
608
+ const memory = await tenantMemory(deps.docker);
609
+ const sizes = others.map((name) => memory?.get(name));
610
+ const total = sizes.every((size) => size !== undefined)
611
+ ? sizes.reduce((sum, size) => sum + size, 0)
612
+ : undefined;
613
+ deps.err(`Also running: ${others.join(", ")}${total === undefined ? "" : ` (about ${formatBytes(total)})`}. "nylorun stop --all" stops them all.`);
614
+ }
508
615
  async function stop(deps, args) {
509
- const usage = "nylorun stop [--name <stack>]";
510
- const flags = parseStackFlags(args, { values: ["--name"] }, usage);
616
+ const usage = "nylorun stop [--tenant <name> | --all]";
617
+ const flags = parseStackFlags(args, { booleans: ["--all"], values: ["--tenant"] }, usage);
511
618
  if (flags.rest.length)
512
619
  throw usageError(`Usage: ${usage}`);
620
+ if (flags.booleans.has("--all")) {
621
+ if (flags.values.has("--tenant"))
622
+ throw usageError(`--all and --tenant are exclusive. Usage: ${usage}`);
623
+ return await stopAll(deps);
624
+ }
513
625
  const ctx = await selectStack(deps, nameOption(flags));
514
626
  requireStackFiles(ctx);
515
627
  await dockerPreflight(deps.docker);
516
628
  const code = await deps.docker.stream(composeArgs(ctx, "stop"));
517
629
  if (code !== 0)
518
630
  throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
519
- deps.out(`Stopped stack ${ctx.name}; volumes are kept.`);
631
+ deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
520
632
  return 0;
521
633
  }
634
+ /** `stop --all`: every running Tenant on this machine, then the Studio proxy; volumes are kept. */
635
+ async function stopAll(deps) {
636
+ await dockerPreflight(deps.docker);
637
+ const projects = (await composeProjects(deps.docker)) ?? new Map();
638
+ const stopped = [];
639
+ for (const tenant of await listTenants(nylorunRoot(deps))) {
640
+ const project = `nylorun-${tenant.name}`;
641
+ if (projects.get(project) !== "running")
642
+ continue;
643
+ const paths = stackPaths(tenant.root);
644
+ const code = await deps.docker.stream(existsSync(paths.compose) && existsSync(paths.env)
645
+ ? composeArgs({ project, paths }, "stop")
646
+ : ["compose", "--project-name", project, "stop"]);
647
+ if (code !== 0)
648
+ throw new CliError(`docker compose stop failed for Tenant ${tenant.name} (exit ${code}).`, 1);
649
+ stopped.push(tenant.name);
650
+ }
651
+ const proxy = projects.get(PROXY_PROJECT) === "running" && (await stopProxy(deps.docker));
652
+ deps.out(stopped.length
653
+ ? `Stopped Tenants ${stopped.join(", ")}${proxy ? " and the Studio proxy" : ""}; their data is kept.`
654
+ : `No Tenant was running${proxy ? "; stopped the Studio proxy" : ""}.`);
655
+ return 0;
656
+ }
657
+ async function studioUrls(ctx, port) {
658
+ const url = await studioUrl(ctx, port);
659
+ const direct = studioOrigin(port);
660
+ return url === direct ? { url } : { url: direct, proxyUrl: url };
661
+ }
522
662
  async function stackStatus(ctx) {
523
663
  const base = {
524
664
  name: ctx.name,
@@ -542,7 +682,7 @@ async function stackStatus(ctx) {
542
682
  const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
543
683
  const host = await readHostConfig(ctx.paths);
544
684
  const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
545
- // The Admin API answers on the operator port; an older stack has only the Runtime port.
685
+ // The Admin API answers on the operator port; an older Host root has only the Runtime port.
546
686
  const adminUrl = persisted.adminPort
547
687
  ? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
548
688
  : runtimeUrl;
@@ -563,7 +703,7 @@ async function stackStatus(ctx) {
563
703
  },
564
704
  ...(tenant ? { tenant } : {}),
565
705
  studio: {
566
- ...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
706
+ ...(persisted.studioPort ? await studioUrls(ctx, persisted.studioPort) : {}),
567
707
  state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
568
708
  ...(persisted.studioFrameAncestors
569
709
  ? { embedOrigins: persisted.studioFrameAncestors }
@@ -579,8 +719,13 @@ async function stackStatus(ctx) {
579
719
  services,
580
720
  };
581
721
  }
722
+ /** The Studio proxy, as data (`nylorun doctor`). */
723
+ export async function readProxyReport(deps) {
724
+ return await readProxyStatus(deps.docker, nylorunRoot(deps), deps.env);
725
+ }
582
726
  /** What `nylorun status` reports, as data (`nylorun doctor`). */
583
727
  export async function readStackStatus(deps, options = {}) {
728
+ await moveStackRoots(nylorunRoot(deps), deps.err);
584
729
  return await stackStatus(await selectStack(deps, options));
585
730
  }
586
731
  function describeTenant(tenant) {
@@ -592,8 +737,8 @@ function describeTenant(tenant) {
592
737
  : `${who} unavailable (opening)`;
593
738
  }
594
739
  async function status(deps, args) {
595
- const usage = "nylorun status [--name <stack>] [--json]";
596
- const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--name"] }, usage);
740
+ const usage = "nylorun status [--tenant <name>] [--json]";
741
+ const flags = parseStackFlags(args, { booleans: ["--json"], values: ["--tenant"] }, usage);
597
742
  if (flags.rest.length)
598
743
  throw usageError(`Usage: ${usage}`);
599
744
  const result = await stackStatus(await selectStack(deps, nameOption(flags)));
@@ -602,20 +747,21 @@ async function status(deps, args) {
602
747
  out(JSON.stringify(result, null, 2));
603
748
  }
604
749
  else if (result.state === "absent") {
605
- out(`Stack ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
750
+ out(`Tenant ${result.name} absent (nothing under ${result.home}; run "nylorun start")`);
606
751
  }
607
752
  else {
608
- out(`Stack ${result.name} ${result.state} (Compose project ${result.project})`);
753
+ out(`Tenant ${result.name} ${result.state} (Compose project ${result.project})`);
609
754
  out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
610
755
  if (result.tenant)
611
- out(`Tenant ${describeTenant(result.tenant)}`);
756
+ out(`Tenant id ${describeTenant(result.tenant)}`);
612
757
  const runtimeDetail = result.runtime.healthy
613
758
  ? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
614
759
  : "not answering";
615
760
  out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
616
761
  if (result.runtime.adminUrl)
617
762
  out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
618
- out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
763
+ const also = result.studio.proxyUrl ? `also ${result.studio.url}; ` : "";
764
+ out(`Studio ${result.studio.proxyUrl ?? result.studio.url ?? "?"} ${result.studio.state} (${also}log in with "nylorun studio")`);
619
765
  if (result.studio.embedOrigins?.length)
620
766
  out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
621
767
  out(`Gateway ${result.gateway.state} (the Model Gate; model calls fail while it is down)`);
@@ -632,8 +778,8 @@ async function status(deps, args) {
632
778
  return result.runtime.healthy ? 0 : 3;
633
779
  }
634
780
  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);
781
+ const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [--tenant <name>] [-f] [--tail <n>]`;
782
+ const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail", "--tenant"], aliases: { "-f": "--follow" } }, usage);
637
783
  if (flags.rest.length > 1)
638
784
  throw usageError(`Usage: ${usage}`);
639
785
  const service = flags.rest[0];
@@ -648,16 +794,16 @@ async function logs(deps, args) {
648
794
  return await deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
649
795
  }
650
796
  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);
797
+ const usage = "nylorun reset [--tenant <name>] [--yes]";
798
+ const flags = parseStackFlags(args, { booleans: ["--yes"], values: ["--tenant"], aliases: { "-y": "--yes" } }, usage);
653
799
  if (flags.rest.length)
654
800
  throw usageError(`Usage: ${usage}`);
655
801
  const ctx = await selectStack(deps, nameOption(flags));
656
802
  const { paths } = ctx;
657
803
  if (!flags.booleans.has("--yes")) {
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] `;
804
+ 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] `;
659
805
  if (!deps.confirm)
660
- throw usageError(`nylorun reset deletes stack ${ctx.name}'s Tenant and all its data; pass --yes to confirm when not in a terminal.`);
806
+ throw usageError(`nylorun reset deletes all of Tenant ${ctx.name}'s data; pass --yes to confirm when not in a terminal.`);
661
807
  if (!(await deps.confirm(question))) {
662
808
  deps.err("Reset cancelled.");
663
809
  return 1;
@@ -665,15 +811,13 @@ async function reset(deps, args) {
665
811
  }
666
812
  if (existsSync(paths.compose) && existsSync(paths.env)) {
667
813
  await dockerPreflight(deps.docker);
668
- const code = await deps.docker.stream(composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
669
- if (code !== 0)
670
- throw new CliError(`docker compose down failed (exit ${code}).`, 1);
814
+ await composeDown(ctx, composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
671
815
  }
672
816
  await rm(paths.tenant, { recursive: true, force: true });
673
817
  await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
674
818
  // The vault key goes with the Tenant's data; the next start writes a new one.
675
819
  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).`);
820
+ 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).`);
677
821
  return 0;
678
822
  }
679
823
  /** Compose projects on this Docker engine and whether any container runs; undefined without Docker. */
@@ -701,18 +845,30 @@ async function ls(deps, args) {
701
845
  const base = nylorunRoot(deps);
702
846
  const projects = await composeProjects(deps.docker);
703
847
  const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
704
- const stacks = [];
705
- for (const entry of await listStacks(base)) {
848
+ const memory = [...(projects?.values() ?? [])].includes("running")
849
+ ? await tenantMemory(deps.docker)
850
+ : undefined;
851
+ const proxy = proxyDisabled(deps.env) ? undefined : await readProxySettings(base);
852
+ const tenants = [];
853
+ for (const entry of await listTenants(base)) {
706
854
  const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
707
- stacks.push({
855
+ const tenantState = state(`nylorun-${entry.name}`);
856
+ tenants.push({
708
857
  name: entry.name,
709
858
  root: entry.root,
710
- ...(entry.record?.project ? { project: entry.record.project } : {}),
711
- state: state(`nylorun-${entry.name}`),
859
+ ...(entry.record.project ? { project: entry.record.project } : {}),
860
+ state: tenantState,
861
+ memoryBytes: (tenantState === "running" && memory?.get(entry.name)) || null,
712
862
  ...(persisted.runtimePort
713
863
  ? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
714
864
  : {}),
715
- ...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
865
+ ...(persisted.studioPort
866
+ ? {
867
+ studioUrl: proxy
868
+ ? proxyOrigin(entry.name, proxy.port)
869
+ : studioOrigin(persisted.studioPort),
870
+ }
871
+ : {}),
716
872
  ports: {
717
873
  ...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
718
874
  ...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
@@ -721,96 +877,54 @@ async function ls(deps, args) {
721
877
  },
722
878
  });
723
879
  }
724
- const legacy = legacyStack(base);
725
- const legacyView = legacy
726
- ? { root: legacy.root, project: legacy.project, state: state(legacy.project) }
727
- : undefined;
728
880
  if (flags.booleans.has("--json")) {
729
- deps.out(JSON.stringify({ stacks, ...(legacyView ? { legacy: legacyView } : {}) }, null, 2));
881
+ deps.out(JSON.stringify({ tenants }, null, 2));
730
882
  return 0;
731
883
  }
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 ?? "-"}`);
884
+ if (tenants.length === 0) {
885
+ deps.out('No Tenants on this machine. Run "npx nylorun start" in a project (or anywhere, for the default Tenant).');
886
+ return 0;
739
887
  }
740
- if (legacyView)
741
- deps.out(`Legacy stack: Compose project nylorun, Host root ${legacyView.root} (${legacyView.state}); "nylorun legacy stop|delete" handles it.`);
888
+ const rows = [
889
+ ["TENANT", "STATE", "MEMORY", "RUNTIME", "STUDIO", "PROJECT"],
890
+ ...tenants.map((tenant) => [
891
+ tenant.name,
892
+ tenant.state,
893
+ tenant.memoryBytes === null ? "-" : formatBytes(tenant.memoryBytes),
894
+ tenant.runtimeUrl ?? "-",
895
+ tenant.studioUrl ?? "-",
896
+ tenant.project ?? "-",
897
+ ]),
898
+ ];
899
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)) + 2);
900
+ for (const row of rows)
901
+ deps.out(row.map((cell, column) => (column < row.length - 1 ? cell.padEnd(widths[column]) : cell)).join(""));
742
902
  return 0;
743
903
  }
744
904
  async function deleteStack(deps, args) {
745
- const usage = "nylorun delete <stack> --yes";
905
+ const usage = "nylorun delete <tenant> --yes";
746
906
  const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, usage);
747
907
  const [name, ...extra] = flags.rest;
748
908
  if (name === undefined || extra.length)
749
909
  throw usageError(`Usage: ${usage}`);
750
- assertStackName(name, "The stack name");
910
+ assertTenantName(name, "The Tenant name");
751
911
  const base = nylorunRoot(deps);
752
- const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(stackRoot(base, name)) };
912
+ const ctx = { deps, project: stackProject(deps.env, name), paths: stackPaths(tenantRoot(base, name)) };
753
913
  if (!existsSync(ctx.paths.root))
754
- throw new CliError(`No stack ${name} under ${stacksDir(base)}. See "nylorun ls".`, 3);
914
+ throw new CliError(`No Tenant ${name} under ${tenantsDir(base)}. See "nylorun ls".`, 3);
755
915
  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.`);
916
+ 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.`);
757
917
  await dockerPreflight(deps.docker);
758
918
  const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
759
- const code = await deps.docker.stream(files
919
+ await composeDown(ctx, files
760
920
  ? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
761
921
  : ["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
922
  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.`);
923
+ await refreshProxyRoutes(deps.docker, base, await proxyRoutes(deps));
924
+ deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
811
925
  return 0;
812
926
  }
813
- /** The stack when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
927
+ /** The Tenant when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
814
928
  async function runningStack(ctx, options) {
815
929
  if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
816
930
  return undefined;
@@ -841,6 +955,7 @@ async function runningStack(ctx, options) {
841
955
  hostId: host.hostId,
842
956
  adminKey,
843
957
  studioPort: persisted.studioPort,
958
+ studioUrl: await studioUrl(ctx, persisted.studioPort),
844
959
  studioUp,
845
960
  started: false,
846
961
  };
@@ -860,12 +975,13 @@ async function ensureSelected(ctx, options) {
860
975
  hostId: started.hostId,
861
976
  adminKey: started.adminKey,
862
977
  studioPort: started.studioPort,
978
+ studioUrl: started.studioUrl,
863
979
  studioUp: started.studioStarted,
864
980
  started: !runtimeWasUp,
865
981
  };
866
982
  }
867
983
  /**
868
- * Start the selected stack unless it is already running (`nylorun studio`): the
984
+ * Start the selected Tenant unless it is already running (`nylorun studio`): the
869
985
  * `start` code path without its output, its Tenant wait or the Project link. Compose
870
986
  * progress still streams, since the first run pulls images.
871
987
  */
@@ -880,52 +996,54 @@ export function withNext(loginUrl, next) {
880
996
  url.searchParams.set("next", next);
881
997
  return url.toString();
882
998
  }
883
- /** The Studio page of the stack's Tenant, as a login `next` path. */
999
+ /** The Tenant's Studio page, as a login `next` path. */
884
1000
  export function tenantStudioPath(tenantId) {
885
1001
  return `/tenants/${encodeURIComponent(tenantId)}`;
886
1002
  }
887
1003
  /**
888
- * Mint a fresh Studio login URL on a running stack, landing on `next`.
1004
+ * Mint a fresh Studio login URL on a running Tenant, landing on `next`.
889
1005
  * Undefined (after a warning) when Studio does not answer.
890
1006
  */
891
- export async function studioLoginUrl(deps, stack, next) {
892
- const login = await tryStudioLogin({ deps }, stack.studioPort, stack.adminKey);
1007
+ export async function studioLoginUrl(deps, running, next) {
1008
+ const login = await tryStudioLogin({ deps }, { port: running.studioPort, url: running.studioUrl }, running.adminKey);
893
1009
  return login === undefined ? undefined : withNext(login, next);
894
1010
  }
895
1011
  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);
1012
+ const usage = "nylorun studio [--tenant <name>] [--no-open]";
1013
+ const flags = parseStackFlags(args, { booleans: ["--no-open"], values: ["--tenant"] }, usage);
898
1014
  if (flags.rest.length)
899
1015
  throw usageError(`Usage: ${usage}`);
900
1016
  const ctx = await selectStack(deps, nameOption(flags));
901
- const stack = await ensureSelected(ctx, { studio: true });
902
- if (stack.started)
903
- deps.out(`Runtime ${stack.runtimeUrl}`);
904
- if (!stack.studioUp)
1017
+ const running = await ensureSelected(ctx, { studio: true });
1018
+ if (running.started)
1019
+ deps.out(`Runtime ${running.runtimeUrl}`);
1020
+ if (!running.studioUp)
905
1021
  throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
906
- // Studio serves the stack's one Tenant; the link's Tenant id stands in while it is opening.
1022
+ // Studio serves the one Tenant; the link's Tenant id stands in while it is opening.
907
1023
  let next = options.next;
908
1024
  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;
1025
+ const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
1026
+ const linked = ctx.link?.tenant === ctx.name ? ctx.link.tenantId : undefined;
911
1027
  const tenantId = tenant?.id ?? linked;
912
1028
  if (tenantId)
913
1029
  next = tenantStudioPath(tenantId);
914
1030
  }
915
- const login = await studioLoginUrl(deps, stack, next);
1031
+ const login = await studioLoginUrl(deps, running, next);
916
1032
  if (!login)
917
1033
  return 1;
918
1034
  if (flags.booleans.has("--no-open")) {
919
1035
  deps.out(`Studio ${login}`);
920
1036
  return 0;
921
1037
  }
922
- const origin = studioOrigin(stack.studioPort);
1038
+ // The login's origin: the proxy's, or Studio's own port when the proxy did not answer.
1039
+ const origin = new URL(login).origin;
923
1040
  deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
924
1041
  await openLogin({ deps }, login);
925
1042
  return 0;
926
1043
  }
927
- /** `nylorun studio`, landing on `next` when given, else on the stack's Tenant. */
1044
+ /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
928
1045
  export async function runStudioCommand(args, deps, options = {}) {
1046
+ await moveStackRoots(nylorunRoot(deps), deps.err);
929
1047
  return await studio(deps, args, options);
930
1048
  }
931
1049
  const COMMANDS = {
@@ -940,15 +1058,15 @@ const COMMANDS = {
940
1058
  studio: (deps, args) => studio(deps, args),
941
1059
  ls,
942
1060
  delete: deleteStack,
943
- legacy,
944
1061
  };
945
1062
  export function isStackCommand(name) {
946
1063
  return name !== undefined && Object.hasOwn(COMMANDS, name);
947
1064
  }
948
- /** Run one stack command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`, `ls`, `delete`, `legacy`). */
1065
+ /** Run one Tenant command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`, `ls`, `delete`). */
949
1066
  export async function runStackCommand(name, args, deps) {
950
1067
  const command = COMMANDS[name];
951
1068
  if (!command)
952
- throw usageError(`Unknown stack command ${name}.\n${stackUsage}`);
1069
+ throw usageError(`Unknown command ${name}.\n${stackUsage}`);
1070
+ await moveStackRoots(nylorunRoot(deps), deps.err);
953
1071
  return await command(deps, args);
954
1072
  }