nylorun 0.5.0-beta → 0.7.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # nylorun
2
2
 
3
+ ## 0.7.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 9af30d8: **The Studio proxy is removed, and Restate runs with its own memory defaults.** Each Tenant's Studio is at `http://localhost:<port>` again (`nylorun studio --tenant <name>` opens it signed in): its own session cookie already keeps it apart from other Studios, so the proxy only added a container and a second address. The 256 MiB RocksDB cap did not lower Restate's memory, so it is gone. The first command of this release removes the proxy that 0.6 started (`nylorun-proxy` and `~/.nylorun/proxy/`); `NYLORUN_PROXY_PORT`, `NYLORUN_PROXY_DISABLED`, `studio.proxyUrl` and Studio's `NYLORUN_STUDIO_PUBLIC_ORIGINS` are gone.
8
+
9
+ ### Patch Changes
10
+
11
+ - Pin studio to the tested release.
12
+
13
+ ## 0.6.0-beta
14
+
15
+ ### Minor Changes
16
+
17
+ - 0cbd5c9: **Breaking (`nylorun`): readable Docker names, several Tenants on one machine, and Studio at `http://<name>.localhost:4160`.** See MIGRATION.md.
18
+
19
+ - **Breaking (`nylorun`): names.** Every container, the network and every volume carries the Tenant's Compose project: containers `nylorun-<name>-postgres`, `-restate`, `-s2-lite`, `-gateway`, `-runtime`, `-studio` (were `…-1`), network `nylorun-<name>` (was `nylorun-<name>_default`), volumes `nylorun-<name>-postgres`, `-restate`, `-s2-lite`, `-workspaces` (were `nylorun-<name>_postgres`, …), each labelled `dev.nylorun.tenant: <name>`. The Compose service `s2` is now `s2-lite` (`nylorun logs s2-lite`).
20
+ - **Breaking (`nylorun`): Tenants created by 0.5 start fresh.** `start` on a Tenant whose data is in the old volumes exits 3 without starting it and names them; `nylorun reset --tenant <name>` starts it anew. `reset` and `delete` also remove the old volumes and the old network.
21
+ - **Studio proxy.** One Caddy container per machine (`nylorun-proxy`, files in `~/.nylorun/proxy/`) gives each Tenant's Studio the address `http://<name>.localhost:<port>` (`NYLORUN_PROXY_PORT`, 4160 or a free port chosen once), on `127.0.0.1` and `[::1]` (IPv4 only, saying so, when Docker refuses `::1`). It holds no Tenant data and routes browsers only. `start` brings it up when Studio starts, prints Studio's proxy URL and signs in there (on Studio's own port when the proxy does not answer); a proxy failure never fails `start`. `status` shows both URLs, `ls` and `nylorun studio` the proxy's. `NYLORUN_PROXY_DISABLED=1` turns it off; a Tenant under `NYLORUN_HOME` or `NYLORUN_COMPOSE_PROJECT` does not use it. `doctor` has a `proxy` row.
22
+ - **Several Tenants.** Restate's RocksDB memory is capped at 256 MiB, so a Tenant uses about 600–700 MB (was about 1.3 GB). `nylorun ls` has a `MEMORY` column (`memoryBytes` in `--json`). `start` names the other running Tenants and their memory. `nylorun stop --all` stops every running Tenant and the proxy, keeping their volumes.
23
+ - **`@nylorun/studio`.** `NYLORUN_STUDIO_PUBLIC_ORIGINS` lists exact `http:` origins Studio also serves (Host and `Origin` checks); a login token's URL is on the origin it was minted on; `NYLORUN_STUDIO_SESSION_COOKIE` names the session cookie (default `nylorun_studio_session`; `nylorun` sets `nylorun_studio_<name>`, so Studios on one host keep their own sessions); the `421` answer lists the served origins.
24
+
25
+ ### Patch Changes
26
+
27
+ - Pin runtime to the tested release.
28
+ - Pin studio to the tested release.
29
+
3
30
  ## 0.5.0-beta
4
31
 
5
32
  ### Minor Changes
package/README.md CHANGED
@@ -36,12 +36,12 @@ npx nylorun doctor # Node 24+, Docker, Compose v2, and the Tenan
36
36
  ```sh
37
37
  nylorun up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset]
38
38
  # create (first run) and start the Tenant; link the project; print the URLs; open Studio signed in
39
- nylorun down|stop [--tenant <name>] # stop the containers; keep volumes
39
+ nylorun down|stop [--tenant <name> | --all] # stop the containers (--all: every Tenant's); keep volumes
40
40
  nylorun status [--tenant <name>] [--json] # the Tenant, its Host root and id, services, endpoints, Runtime health
41
- nylorun logs [service] [--tenant <name>] [-f] [--tail <n>] # postgres, restate, s2, gateway, runtime, studio
41
+ nylorun logs [service] [--tenant <name>] [-f] [--tail <n>] # postgres, restate, s2-lite, gateway, runtime, studio
42
42
  nylorun studio [--tenant <name>] [--no-open] # sign a browser in to Studio on the Tenant; starts it if needed
43
43
  nylorun reset [--tenant <name>] [--yes] # delete the Tenant's volumes, Tenant directory and vault key; the next start creates it anew
44
- nylorun ls [--json] # the Tenants on this machine
44
+ nylorun ls [--json] # the Tenants on this machine, with their state, memory and URLs
45
45
  nylorun delete <tenant> --yes # remove a Tenant: containers, volumes, Host root and vault key
46
46
  nylorun doctor [--json] # prerequisites and the Tenant's health
47
47
  nylorun telemetry [status|enable|disable] # Studio's anonymous usage analytics
@@ -78,6 +78,42 @@ name). nylorun 0.4 kept Host roots in `~/.nylorun/stacks/`: the first command
78
78
  of this release moves them to `~/.nylorun/tenants/`, keeping their Compose
79
79
  projects and volumes.
80
80
 
81
+ ### Several Tenants
82
+
83
+ Tenants run side by side, each on its own ports, containers, network and
84
+ volumes ([The containers](#the-containers)); a running Tenant uses about
85
+ 1.2 GB. Each Studio is on its own port, `http://localhost:<port>`, with its own
86
+ session cookie, so two Studios in one browser stay signed in;
87
+ `nylorun studio --tenant <name>` opens any Tenant's Studio signed in. `nylorun ls`
88
+ lists them with their state, the memory their containers use (`MEMORY`: the sum
89
+ of `docker stats` over the containers labelled `dev.nylorun.tenant=<name>`, `-`
90
+ when stopped; `memoryBytes` in `--json`, `null` when stopped or unknown), the
91
+ Runtime's URL and Studio's:
92
+
93
+ ```text
94
+ TENANT STATE MEMORY RUNTIME STUDIO PROJECT
95
+ api running 1.2 GB http://localhost:8790 http://localhost:4162 /Users/me/api
96
+ default stopped - http://localhost:8787 http://localhost:4161 -
97
+ ```
98
+
99
+ When other Tenants are running, `start` says so after its summary, on stderr
100
+ (without the size when Docker does not report it):
101
+
102
+ ```text
103
+ Also running: default, api (about 2.4 GB). "nylorun stop --all" stops them all.
104
+ ```
105
+
106
+ `nylorun stop --all` stops every running Tenant on this machine, keeping their
107
+ volumes; it cannot be combined with `--tenant` (exit 2).
108
+
109
+ A Tenant created by nylorun 0.5 keeps its data in volumes Compose named
110
+ `<project>_postgres`, `<project>_restate`, `<project>_s2` and
111
+ `<project>_workspaces`; this release names them `<project>-postgres`, … (see
112
+ [The containers](#the-containers)). `start` on such a Tenant exits 3 without
113
+ starting it, naming the old volumes: run `nylorun reset --tenant <name>` to
114
+ start it fresh. `reset` and `delete` also remove the old volumes (and the old
115
+ network `<project>_default`).
116
+
81
117
  A Tenant refuses only a Runtime older than its own database: `start` records
82
118
  the pinned Runtime version in `host.json` (`runtimeVersion`) and exits 5 when
83
119
  this nylorun pins an older one. Update nylorun, or pass `--allow-downgrade`
@@ -119,9 +155,21 @@ The Runtime registers the derived principals of `NYLORUN_DERIVED_PRINCIPALS`
119
155
  ## The containers
120
156
 
121
157
  `nylorun start` writes `compose.yaml` and `.env` (mode 0600) under
122
- `~/.nylorun/tenants/<name>/docker/` on the first run and reuses them after that: `postgres`,
123
- `restate`, `s2`, `gateway`, `runtime` and `studio`. Postgres initialises the
124
- Tenant's database with C collation. The Runtime and Studio images are pinned by
158
+ `~/.nylorun/tenants/<name>/docker/` on every start; `.env` keeps the ports and
159
+ secrets chosen on the first run. The services are `postgres`, `restate`,
160
+ `s2-lite`, `gateway`, `runtime` and `studio`. Container, network and volume
161
+ names are global on the Docker engine, so each carries the Tenant's Compose
162
+ project, and each has the label `dev.nylorun.tenant: <name>`. For Tenant `shop`:
163
+
164
+ | Thing | Name |
165
+ | --- | --- |
166
+ | Containers | `nylorun-shop-postgres`, `nylorun-shop-restate`, `nylorun-shop-s2-lite`, `nylorun-shop-gateway`, `nylorun-shop-runtime`, `nylorun-shop-studio` |
167
+ | Network | `nylorun-shop` |
168
+ | Volumes | `nylorun-shop-postgres`, `nylorun-shop-restate`, `nylorun-shop-s2-lite`, `nylorun-shop-workspaces` |
169
+
170
+ Postgres initialises the Tenant's database with C collation. Restate runs with
171
+ its own defaults. A running Tenant uses about 1.2 GB, most of it Restate; stop
172
+ the Tenants you are not using (`nylorun stop`, or `nylorun stop --all`). The Runtime and Studio images are pinned by
125
173
  this release (`package.json` `nylorun.runtime` and `nylorun.studio`);
126
174
  `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them (local builds,
127
175
  CI). Ports publish on loopback only: the Runtime on `8787`, its operator
@@ -129,15 +177,21 @@ listener (Admin API) on `8788`, Studio on `4161` and the Restate UI on `9070`,
129
177
  or free ports chosen on the Tenant's first start, avoiding the ports other
130
178
  Tenants keep, and kept in its `.env`.
131
179
 
132
- `start` prints Studio as `http://localhost:<port>`. Studio has no password: in a
133
- terminal (not in CI, and not with `--no-open`), `start` asks the Studio container
134
- for a single-use login token with the admin key (`POST /_studio/login-tokens`)
135
- and opens `http://localhost:<port>/login?token=…` on the Tenant's page in the
136
- browser. That sets a session cookie for 30 days, which survives Studio
137
- restarts, so the printed URL keeps working in that browser. The token itself is
138
- never printed unless no browser starts. Otherwise `start` says to run `nylorun
139
- studio`, which signs a browser in the same way; `nylorun studio --no-open`
140
- prints the login URL (it works once, for two minutes) instead.
180
+ `start`, `status`, `ls` and `nylorun studio` give Studio as
181
+ `http://localhost:<port>` (`studio.url` in `status --json`). Studio has no
182
+ password: in a terminal (not in CI, and not with `--no-open`), `start` asks the
183
+ Studio container for a single-use login token with the admin key (`POST
184
+ /_studio/login-tokens`) and opens `http://localhost:<port>/login?token=…` on the
185
+ Tenant's page in the browser. That sets a session cookie for 30 days, which
186
+ survives Studio restarts, so the printed URL keeps working in that browser. The
187
+ token itself is never printed unless no browser starts. Otherwise `start` says
188
+ to run `nylorun studio`, which signs a browser in the same way; `nylorun studio
189
+ --no-open` prints the login URL (it works once, for two minutes) instead.
190
+
191
+ Browsers share cookies across the ports of one host, so each Tenant's Studio
192
+ sets its own session cookie, `nylorun_studio_<name>`
193
+ (`NYLORUN_STUDIO_SESSION_COOKIE`): signing in to one Studio does not sign you
194
+ out of another.
141
195
 
142
196
  ### Embedding Studio in a desktop app
143
197
 
@@ -201,7 +255,7 @@ containers, volumes and Host root: the vault key (KEK) and all its data go with
201
255
  | 0 | Success |
202
256
  | 1 | Generic failure, including a missing Docker or Compose v2 |
203
257
  | 2 | Usage error (including no Tenant selected, or `delete` without `--yes`), or a removed or moved command |
204
- | 3 | `status`: the Runtime is not answering; `stop`/`logs`: no Tenant yet; `delete`: no such Tenant |
258
+ | 3 | `status`: the Runtime is not answering; `stop`/`logs`: no Tenant yet; `delete`: no such Tenant; `start`: a Tenant created by nylorun 0.5 (reset it) |
205
259
  | 4 | The Runtime port is held by another Host |
206
260
  | 5 | `up`/`start`/`studio`: this nylorun pins a Runtime older than the one that last ran the Tenant; update nylorun, or `start --allow-downgrade` |
207
261
  | 7 | The containers, the Tenant or Studio did not become ready (an unavailable Tenant's cause is printed) |
@@ -218,5 +272,7 @@ containers, volumes and Host root: the vault key (KEK) and all its data go with
218
272
  | Port conflict | Change `NYLORUN_PORT` / `NYLORUN_STUDIO_PORT` in `~/.nylorun/tenants/<name>/docker/.env` |
219
273
  | Logs | `nylorun logs runtime -f` |
220
274
  | Studio login expired | `nylorun studio` |
275
+ | Which Studio is which | `nylorun ls` lists each Tenant's Studio URL; `nylorun studio --tenant <name>` opens one signed in |
276
+ | `Tenant … was created by nylorun 0.5` (exit 3) | `nylorun reset --tenant <name>` (its data starts fresh) |
221
277
 
222
278
  See [MIGRATION.md](../MIGRATION.md).
@@ -2,7 +2,7 @@ import type { HostTenant } from "@nylorun/core/contracts";
2
2
  import { type ComposeService, type DockerRunner } from "./docker.js";
3
3
  import type { PortProbe } from "./ports.js";
4
4
  import { type FetchLike } from "./studio-login.js";
5
- export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
5
+ export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
6
6
  export declare const stackUsage: string;
7
7
  export interface StackDeps {
8
8
  /** Environment snapshot (NYLORUN_HOME, NYLORUN_TENANT, image overrides, NYLORUN_COMPOSE_PROJECT). */
@@ -73,6 +73,7 @@ export interface StackStatus {
73
73
  /** The Tenant as the Runtime reports it, while it answers. */
74
74
  tenant?: StackTenant;
75
75
  studio: {
76
+ /** `http://localhost:<port>`: also what an embedding app frames (same site as its own localhost). */
76
77
  url?: string;
77
78
  state: string;
78
79
  /** Exact origins that may show Studio in a frame (Studio §8.9). */
@@ -105,6 +106,8 @@ export interface StackEndpoints {
105
106
  hostId: string;
106
107
  adminKey: string;
107
108
  studioPort: number;
109
+ /** `http://localhost:<studio port>` */
110
+ studioUrl: string;
108
111
  /** Studio is running and healthy. */
109
112
  studioUp: boolean;
110
113
  /** This call started the Runtime (it was not running before). */
@@ -8,28 +8,28 @@ import { readProjectCredentials, readProjectLink, writeProjectCredentials, write
8
8
  import { findProjectRoot } from "../project/root.js";
9
9
  import { seedTenant } from "../project/seed.js";
10
10
  import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
11
- import { dockerPreflight, parseComposePs, } from "./docker.js";
11
+ import { dockerPreflight, formatBytes, parseComposePs, tenantMemory, } from "./docker.js";
12
12
  import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
13
13
  import { runtimeImageOverridden, stackImages } from "./images.js";
14
14
  import { stackPaths } from "./paths.js";
15
15
  import { prepareStack, readStackEnv } from "./prepare.js";
16
- import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
16
+ import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, removeOldProxy, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
17
17
  import { mintStudioLogin, studioOrigin } from "./studio-login.js";
18
- export const STACK_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime", "studio"];
19
- const CORE_SERVICES = ["postgres", "restate", "s2", "gateway", "runtime"];
18
+ export const STACK_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime", "studio"];
19
+ const CORE_SERVICES = ["postgres", "restate", "s2-lite", "gateway", "runtime"];
20
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
21
  start the project's Tenant, creating it and the Project link (.nylorun/link.json,
22
22
  credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
23
23
  signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
24
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
25
+ down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's); keep volumes
26
26
  status [--tenant <name>] [--json] the Tenant, its id, services, endpoints and Runtime health
27
27
  logs [service] [--tenant <name>] [-f] [--tail <n>]
28
28
  container logs (${STACK_SERVICES.join(", ")})
29
29
  studio [--tenant <name>] [--no-open]
30
30
  sign a browser in to Studio on the Tenant; --no-open prints the login URL; start the Tenant if it is stopped
31
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
32
+ ls [--json] the Tenants on this machine, with their memory in use
33
33
  delete <tenant> --yes remove a Tenant: containers, volumes and its Host root, with the vault key (KEK)`;
34
34
  const usageError = (message) => new CliError(message, 2);
35
35
  /** The Compose project of Tenant `name`: `nylorun-<name>`, or `NYLORUN_COMPOSE_PROJECT`. */
@@ -42,6 +42,38 @@ export function stackProject(env, name) {
42
42
  function nylorunRoot(deps) {
43
43
  return deps.nylorunRoot ?? defaultNylorunRoot();
44
44
  }
45
+ /** Before every Tenant command: move 0.4's Host roots, remove 0.6's Studio proxy. */
46
+ async function tidyMachine(deps) {
47
+ const base = nylorunRoot(deps);
48
+ await moveStackRoots(base, deps.err);
49
+ await removeOldProxy(deps.docker, base, deps.err);
50
+ }
51
+ /**
52
+ * Compose's names for a 0.5 Tenant's volumes (`<project>_postgres`, …) that exist on this
53
+ * engine, and whether this release's `<project>-postgres` does.
54
+ */
55
+ async function oldVolumes(docker, project) {
56
+ const result = await docker.run(["volume", "ls", "--quiet", "--filter", `name=${project}`]);
57
+ const names = new Set(result.code === 0 ? result.stdout.split(/\s+/) : []);
58
+ return {
59
+ old: ["postgres", "restate", "s2", "workspaces"]
60
+ .map((volume) => `${project}_${volume}`)
61
+ .filter((name) => names.has(name)),
62
+ current: names.has(`${project}-postgres`),
63
+ };
64
+ }
65
+ /** `docker compose down` (`reset`, `delete`), then drop a 0.5 Tenant's volumes and network. */
66
+ async function composeDown(ctx, args) {
67
+ const { docker } = ctx.deps;
68
+ const code = await docker.stream(args);
69
+ if (code !== 0)
70
+ throw new CliError(`docker compose down failed (exit ${code}).`, 1);
71
+ const { old } = await oldVolumes(docker, ctx.project);
72
+ if (old.length) {
73
+ await docker.run(["volume", "rm", ...old]);
74
+ await docker.run(["network", "rm", `${ctx.project}_default`]);
75
+ }
76
+ }
45
77
  async function tenantNames(deps) {
46
78
  const names = (await listTenants(nylorunRoot(deps))).map((tenant) => tenant.name);
47
79
  return names.length ? ` Tenants on this machine: ${names.join(", ")}.` : "";
@@ -285,10 +317,10 @@ async function refuseDowngrade(ctx, allowDowngrade) {
285
317
  }
286
318
  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
319
  }
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)
320
+ /** Ports the Tenants on this machine keep in their `.env` (but the one under `except`). */
321
+ async function tenantPorts(base, except) {
322
+ const others = (await listTenants(base))
323
+ .filter((tenant) => resolve(tenant.root) !== except)
292
324
  .map((tenant) => stackPaths(tenant.root));
293
325
  const reserved = new Set();
294
326
  for (const paths of others) {
@@ -304,13 +336,21 @@ async function reservedPorts(ctx) {
304
336
  }
305
337
  return reserved;
306
338
  }
339
+ /** Ports other Tenants keep, so a new Tenant avoids them. */
340
+ async function reservedPorts(ctx) {
341
+ return await tenantPorts(nylorunRoot(ctx.deps), ctx.paths.root);
342
+ }
307
343
  async function bringUp(ctx, options) {
308
344
  const { deps } = ctx;
309
345
  await refuseLauncherRuntime(ctx);
310
346
  const overridden = runtimeImageOverridden(deps.env);
311
347
  if (!overridden)
312
348
  await refuseDowngrade(ctx, options.allowDowngrade ?? false);
313
- const telemetry = await readTelemetry(nylorunRoot(deps));
349
+ const legacy = await oldVolumes(deps.docker, ctx.project);
350
+ if (legacy.old.length && !legacy.current)
351
+ 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);
352
+ const base = nylorunRoot(deps);
353
+ const telemetry = await readTelemetry(base);
314
354
  const analytics = telemetryDecision(deps.env, telemetry).enabled;
315
355
  const prepared = await prepareStack({
316
356
  paths: ctx.paths,
@@ -365,25 +405,30 @@ async function bringUp(ctx, options) {
365
405
  adminUrl,
366
406
  hostId: prepared.host.hostId,
367
407
  studioPort: prepared.env.studioPort,
408
+ studioUrl: studioOrigin(prepared.env.studioPort),
368
409
  studioStarted,
369
410
  adminKey: prepared.adminKey,
370
411
  };
371
412
  }
413
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
414
+ /** A login URL on Studio's own port, retried while it starts. */
372
415
  async function tryStudioLogin(ctx, studioPort, adminKey) {
373
- const deadline = Date.now() + (ctx.deps.loginTimeoutMs ?? 15_000);
416
+ const { deps } = ctx;
417
+ const origin = studioOrigin(studioPort);
418
+ const deadline = Date.now() + (deps.loginTimeoutMs ?? 15_000);
374
419
  let lastError = "";
375
420
  for (;;) {
376
421
  try {
377
- return await mintStudioLogin({ fetch: ctx.deps.fetch, studioPort, adminKey });
422
+ return await mintStudioLogin({ fetch: deps.fetch, origin, adminKey });
378
423
  }
379
424
  catch (error) {
380
- lastError = error instanceof Error ? error.message : String(error);
425
+ lastError = errorText(error);
381
426
  }
382
427
  if (Date.now() > deadline)
383
428
  break;
384
- await sleep(ctx.deps.pollMs ?? 500);
429
+ await sleep(deps.pollMs ?? 500);
385
430
  }
386
- ctx.deps.err(`Warning: Studio at ${studioOrigin(studioPort)} is not reachable for a login (${lastError}).`);
431
+ deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
387
432
  return undefined;
388
433
  }
389
434
  /** Printed by `start` when it does not open Studio itself. */
@@ -483,7 +528,7 @@ async function start(deps, args) {
483
528
  deps.out(`Tenant ${ctx.name} (${tenant.id})`);
484
529
  deps.out(`Runtime ${started.runtimeUrl}`);
485
530
  if (started.studioStarted) {
486
- deps.out(`Studio ${studioOrigin(started.studioPort)}`);
531
+ deps.out(`Studio ${started.studioUrl}`);
487
532
  if (opensBrowser(deps, flags)) {
488
533
  const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
489
534
  if (login)
@@ -492,13 +537,37 @@ async function start(deps, args) {
492
537
  else
493
538
  deps.err(STUDIO_SIGN_IN_HINT);
494
539
  }
540
+ await alsoRunning(ctx);
495
541
  return 0;
496
542
  }
543
+ /** After `start`: the machine's other running Tenants and their memory, on stderr. */
544
+ async function alsoRunning(ctx) {
545
+ const { deps } = ctx;
546
+ const projects = await composeProjects(deps.docker);
547
+ if (!projects)
548
+ return;
549
+ const others = (await listTenants(nylorunRoot(deps)))
550
+ .map((tenant) => tenant.name)
551
+ .filter((name) => name !== ctx.name && projects.get(`nylorun-${name}`) === "running");
552
+ if (others.length === 0)
553
+ return;
554
+ const memory = await tenantMemory(deps.docker);
555
+ const sizes = others.map((name) => memory?.get(name));
556
+ const total = sizes.every((size) => size !== undefined)
557
+ ? sizes.reduce((sum, size) => sum + size, 0)
558
+ : undefined;
559
+ deps.err(`Also running: ${others.join(", ")}${total === undefined ? "" : ` (about ${formatBytes(total)})`}. "nylorun stop --all" stops them all.`);
560
+ }
497
561
  async function stop(deps, args) {
498
- const usage = "nylorun stop [--tenant <name>]";
499
- const flags = parseStackFlags(args, { values: ["--tenant"] }, usage);
562
+ const usage = "nylorun stop [--tenant <name> | --all]";
563
+ const flags = parseStackFlags(args, { booleans: ["--all"], values: ["--tenant"] }, usage);
500
564
  if (flags.rest.length)
501
565
  throw usageError(`Usage: ${usage}`);
566
+ if (flags.booleans.has("--all")) {
567
+ if (flags.values.has("--tenant"))
568
+ throw usageError(`--all and --tenant are exclusive. Usage: ${usage}`);
569
+ return await stopAll(deps);
570
+ }
502
571
  const ctx = await selectStack(deps, nameOption(flags));
503
572
  requireStackFiles(ctx);
504
573
  await dockerPreflight(deps.docker);
@@ -508,6 +577,28 @@ async function stop(deps, args) {
508
577
  deps.out(`Stopped Tenant ${ctx.name}; its data is kept.`);
509
578
  return 0;
510
579
  }
580
+ /** `stop --all`: every running Tenant on this machine; volumes are kept. */
581
+ async function stopAll(deps) {
582
+ await dockerPreflight(deps.docker);
583
+ const projects = (await composeProjects(deps.docker)) ?? new Map();
584
+ const stopped = [];
585
+ for (const tenant of await listTenants(nylorunRoot(deps))) {
586
+ const project = `nylorun-${tenant.name}`;
587
+ if (projects.get(project) !== "running")
588
+ continue;
589
+ const paths = stackPaths(tenant.root);
590
+ const code = await deps.docker.stream(existsSync(paths.compose) && existsSync(paths.env)
591
+ ? composeArgs({ project, paths }, "stop")
592
+ : ["compose", "--project-name", project, "stop"]);
593
+ if (code !== 0)
594
+ throw new CliError(`docker compose stop failed for Tenant ${tenant.name} (exit ${code}).`, 1);
595
+ stopped.push(tenant.name);
596
+ }
597
+ deps.out(stopped.length
598
+ ? `Stopped Tenants ${stopped.join(", ")}; their data is kept.`
599
+ : "No Tenant was running.");
600
+ return 0;
601
+ }
511
602
  async function stackStatus(ctx) {
512
603
  const base = {
513
604
  name: ctx.name,
@@ -570,7 +661,7 @@ async function stackStatus(ctx) {
570
661
  }
571
662
  /** What `nylorun status` reports, as data (`nylorun doctor`). */
572
663
  export async function readStackStatus(deps, options = {}) {
573
- await moveStackRoots(nylorunRoot(deps), deps.err);
664
+ await tidyMachine(deps);
574
665
  return await stackStatus(await selectStack(deps, options));
575
666
  }
576
667
  function describeTenant(tenant) {
@@ -655,9 +746,7 @@ async function reset(deps, args) {
655
746
  }
656
747
  if (existsSync(paths.compose) && existsSync(paths.env)) {
657
748
  await dockerPreflight(deps.docker);
658
- const code = await deps.docker.stream(composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
659
- if (code !== 0)
660
- throw new CliError(`docker compose down failed (exit ${code}).`, 1);
749
+ await composeDown(ctx, composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
661
750
  }
662
751
  await rm(paths.tenant, { recursive: true, force: true });
663
752
  await mkdir(paths.tenant, { recursive: true, mode: 0o700 });
@@ -691,14 +780,19 @@ async function ls(deps, args) {
691
780
  const base = nylorunRoot(deps);
692
781
  const projects = await composeProjects(deps.docker);
693
782
  const state = (project) => (projects ? (projects.get(project) ?? "stopped") : "unknown");
783
+ const memory = [...(projects?.values() ?? [])].includes("running")
784
+ ? await tenantMemory(deps.docker)
785
+ : undefined;
694
786
  const tenants = [];
695
787
  for (const entry of await listTenants(base)) {
696
788
  const persisted = (await readStackEnv(stackPaths(entry.root))) ?? {};
789
+ const tenantState = state(`nylorun-${entry.name}`);
697
790
  tenants.push({
698
791
  name: entry.name,
699
792
  root: entry.root,
700
793
  ...(entry.record.project ? { project: entry.record.project } : {}),
701
- state: state(`nylorun-${entry.name}`),
794
+ state: tenantState,
795
+ memoryBytes: (tenantState === "running" && memory?.get(entry.name)) || null,
702
796
  ...(persisted.runtimePort
703
797
  ? { runtimeUrl: `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}` }
704
798
  : {}),
@@ -719,10 +813,20 @@ async function ls(deps, args) {
719
813
  deps.out('No Tenants on this machine. Run "npx nylorun start" in a project (or anywhere, for the default Tenant).');
720
814
  return 0;
721
815
  }
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 ?? "-"}`);
816
+ const rows = [
817
+ ["TENANT", "STATE", "MEMORY", "RUNTIME", "STUDIO", "PROJECT"],
818
+ ...tenants.map((tenant) => [
819
+ tenant.name,
820
+ tenant.state,
821
+ tenant.memoryBytes === null ? "-" : formatBytes(tenant.memoryBytes),
822
+ tenant.runtimeUrl ?? "-",
823
+ tenant.studioUrl ?? "-",
824
+ tenant.project ?? "-",
825
+ ]),
826
+ ];
827
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)) + 2);
828
+ for (const row of rows)
829
+ deps.out(row.map((cell, column) => (column < row.length - 1 ? cell.padEnd(widths[column]) : cell)).join(""));
726
830
  return 0;
727
831
  }
728
832
  async function deleteStack(deps, args) {
@@ -740,11 +844,9 @@ async function deleteStack(deps, args) {
740
844
  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
845
  await dockerPreflight(deps.docker);
742
846
  const files = existsSync(ctx.paths.compose) && existsSync(ctx.paths.env);
743
- const code = await deps.docker.stream(files
847
+ await composeDown(ctx, files
744
848
  ? composeArgs(ctx, "down", "--volumes", "--remove-orphans")
745
849
  : ["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
850
  await rm(ctx.paths.root, { recursive: true, force: true });
749
851
  deps.out(`Deleted Tenant ${name}: its containers, volumes and Host root (vault key included).`);
750
852
  return 0;
@@ -780,6 +882,7 @@ async function runningStack(ctx, options) {
780
882
  hostId: host.hostId,
781
883
  adminKey,
782
884
  studioPort: persisted.studioPort,
885
+ studioUrl: studioOrigin(persisted.studioPort),
783
886
  studioUp,
784
887
  started: false,
785
888
  };
@@ -799,6 +902,7 @@ async function ensureSelected(ctx, options) {
799
902
  hostId: started.hostId,
800
903
  adminKey: started.adminKey,
801
904
  studioPort: started.studioPort,
905
+ studioUrl: started.studioUrl,
802
906
  studioUp: started.studioStarted,
803
907
  started: !runtimeWasUp,
804
908
  };
@@ -858,14 +962,14 @@ async function studio(deps, args, options = {}) {
858
962
  deps.out(`Studio ${login}`);
859
963
  return 0;
860
964
  }
861
- const origin = studioOrigin(running.studioPort);
965
+ const origin = new URL(login).origin;
862
966
  deps.out(`Studio ${next ? new URL(next, origin).toString() : origin}`);
863
967
  await openLogin({ deps }, login);
864
968
  return 0;
865
969
  }
866
970
  /** `nylorun studio`, landing on `next` when given, else on the Tenant's page. */
867
971
  export async function runStudioCommand(args, deps, options = {}) {
868
- await moveStackRoots(nylorunRoot(deps), deps.err);
972
+ await tidyMachine(deps);
869
973
  return await studio(deps, args, options);
870
974
  }
871
975
  const COMMANDS = {
@@ -889,6 +993,6 @@ export async function runStackCommand(name, args, deps) {
889
993
  const command = COMMANDS[name];
890
994
  if (!command)
891
995
  throw usageError(`Unknown command ${name}.\n${stackUsage}`);
892
- await moveStackRoots(nylorunRoot(deps), deps.err);
996
+ await tidyMachine(deps);
893
997
  return await command(deps, args);
894
998
  }
@@ -2,8 +2,11 @@
2
2
  * A local Tenant's Compose file (Runtime Architecture §14.3), written to
3
3
  * `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
4
4
  * (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
5
- * network and ports. Everything else that varies (ports, the Postgres password, UID/GID, the
6
- * Host root, the images, the Tenant's name) comes from `docker/.env`.
5
+ * network and ports. Container, network and volume names are global on the Docker engine, so
6
+ * every one is `<project>-<role>` (`nylorun-shop-studio`, network `nylorun-shop`, volume
7
+ * `nylorun-shop-postgres`), labelled `dev.nylorun.tenant: <name>`; they are written here, from
8
+ * the project and the Tenant's name. Everything else that varies (ports, the Postgres password,
9
+ * UID/GID, the Host root, the images) comes from `docker/.env`.
7
10
  *
8
11
  * The Tenant's state is its Postgres database, executed through Restate, with its
9
12
  * history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
@@ -33,4 +36,4 @@
33
36
  * it is down, model and MCP calls fail, deliveries are retried, vault writes and
34
37
  * token minting answer 503, and the session takes the next message.
35
38
  */
36
- export declare function renderComposeFile(project: string): string;
39
+ export declare function renderComposeFile(project: string, name: string): string;
@@ -3,8 +3,11 @@ import { PINNED_IMAGES } from "./images.js";
3
3
  * A local Tenant's Compose file (Runtime Architecture §14.3), written to
4
4
  * `<Host root>/docker/compose.yaml` by `nylorun start`. Each local Tenant is one installation
5
5
  * (tenancy.md §6): its own Compose project (`nylorun-<name>`, the file's `name`), volumes,
6
- * network and ports. Everything else that varies (ports, the Postgres password, UID/GID, the
7
- * Host root, the images, the Tenant's name) comes from `docker/.env`.
6
+ * network and ports. Container, network and volume names are global on the Docker engine, so
7
+ * every one is `<project>-<role>` (`nylorun-shop-studio`, network `nylorun-shop`, volume
8
+ * `nylorun-shop-postgres`), labelled `dev.nylorun.tenant: <name>`; they are written here, from
9
+ * the project and the Tenant's name. Everything else that varies (ports, the Postgres password,
10
+ * UID/GID, the Host root, the images) comes from `docker/.env`.
8
11
  *
9
12
  * The Tenant's state is its Postgres database, executed through Restate, with its
10
13
  * history in s2-lite; the Runtime's /ready checks all three. Postgres initialises the
@@ -34,13 +37,18 @@ import { PINNED_IMAGES } from "./images.js";
34
37
  * it is down, model and MCP calls fail, deliveries are retried, vault writes and
35
38
  * token minting answer 503, and the session takes the next message.
36
39
  */
37
- export function renderComposeFile(project) {
40
+ export function renderComposeFile(project, name) {
38
41
  return `# Written by \`nylorun start\`; rewritten on every start. Settings live in .env.
39
42
  name: ${project}
40
43
 
44
+ x-tenant: &tenant
45
+ dev.nylorun.tenant: "${name}"
46
+
41
47
  services:
42
48
  postgres: # Session Store
43
49
  image: ${PINNED_IMAGES.postgres}
50
+ container_name: ${project}-postgres
51
+ labels: *tenant
44
52
  # Logical replication feeds the stream relay; a stuck slot is capped at 4 GB of WAL.
45
53
  command: ["postgres", "-c", "wal_level=logical", "-c", "max_slot_wal_keep_size=4GB"]
46
54
  environment:
@@ -59,6 +67,8 @@ services:
59
67
 
60
68
  restate: # Durable Session Execution
61
69
  image: ${PINNED_IMAGES.restate}
70
+ container_name: ${project}-restate
71
+ labels: *tenant
62
72
  command: ["--node-name=restate-1"] # stable name, so data is found on restart
63
73
  environment:
64
74
  RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE: /run/nylorun/restate-identity.pem
@@ -74,20 +84,24 @@ services:
74
84
  retries: 30
75
85
  restart: unless-stopped
76
86
 
77
- s2: # Durable Streams; not published, only the Runtime reaches it
87
+ s2-lite: # Durable Streams; not published, only the Runtime reaches it
78
88
  image: ${PINNED_IMAGES.s2}
89
+ container_name: ${project}-s2-lite
90
+ labels: *tenant
79
91
  command: ["lite", "--local-root", "/home/nonroot/data"] # local disk; listens on port 80
80
92
  # The image runs as uid 65532. Docker fills a new volume with the image's
81
93
  # /home/nonroot, owned by that user; a volume at a path the image lacks
82
94
  # (e.g. /data) is root-owned and s2-lite cannot write it.
83
95
  volumes:
84
- - s2:/home/nonroot
96
+ - s2-lite:/home/nonroot
85
97
  # The s2 image has no shell or HTTP client, so it has no health check;
86
98
  # the Runtime's /ready covers it.
87
99
  restart: unless-stopped
88
100
 
89
101
  gateway: # the Model and Tool Gates and keys: the vault key, credentials, signing, outbound calls; not published
90
102
  image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
103
+ container_name: ${project}-gateway
104
+ labels: *tenant
91
105
  command: ["--service", "gates,keys"]
92
106
  user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
93
107
  depends_on:
@@ -116,12 +130,14 @@ services:
116
130
 
117
131
  runtime:
118
132
  image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
133
+ container_name: ${project}-runtime
134
+ labels: *tenant
119
135
  command: ["--service", "core,loop"]
120
136
  user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
121
137
  depends_on:
122
138
  postgres: { condition: service_healthy }
123
139
  restate: { condition: service_healthy }
124
- s2: { condition: service_started }
140
+ s2-lite: { condition: service_started }
125
141
  environment:
126
142
  NYLORUN_HOME: /nylorun
127
143
  NYLORUN_PACKING: combined
@@ -149,7 +165,7 @@ services:
149
165
  NYLORUN_RESTATE_ADMIN_URL: http://restate:9070
150
166
  NYLORUN_WORKER_URL: http://runtime:9080 # registered with Restate; not published
151
167
  NYLORUN_RESTATE_IDENTITY_KEY: \${NYLORUN_RESTATE_IDENTITY_KEY:?run nylorun start}
152
- NYLORUN_S2_ENDPOINT: http://s2:80
168
+ NYLORUN_S2_ENDPOINT: http://s2-lite:80
153
169
  NYLORUN_S2_TOKEN: ignored # s2-lite has no access tokens yet
154
170
  NYLORUN_WORKSPACE_STORE_URL: file:///workspaces
155
171
  # Action endpoints on this machine: \`localhost\` in a registered URL means the Docker host.
@@ -181,6 +197,8 @@ services:
181
197
 
182
198
  studio: # dashboard + trusted proxy
183
199
  image: \${NYLORUN_STUDIO_IMAGE:?run nylorun start}
200
+ container_name: ${project}-studio
201
+ labels: *tenant
184
202
  user: "\${NYLORUN_UID}:\${NYLORUN_GID}" # reads the 0600 admin key file
185
203
  depends_on:
186
204
  runtime: { condition: service_healthy }
@@ -190,6 +208,9 @@ services:
190
208
  PORT: "3000"
191
209
  # Studio's Host check accepts localhost/127.0.0.1 on the published port.
192
210
  NYLORUN_STUDIO_PUBLIC_PORT: \${NYLORUN_STUDIO_PORT}
211
+ # Browsers share cookies across ports of one host: a cookie per Tenant keeps the
212
+ # sessions of two Studios on localhost apart.
213
+ NYLORUN_STUDIO_SESSION_COOKIE: nylorun_studio_${name}
193
214
  # Exact origins that may frame Studio (Babai Desktop); kept in .env.
194
215
  NYLORUN_STUDIO_FRAME_ANCESTORS: \${NYLORUN_STUDIO_FRAME_ANCESTORS:-}
195
216
  # Studio's anonymous usage analytics; empty when telemetry is off.
@@ -205,10 +226,15 @@ services:
205
226
  retries: 30
206
227
  restart: unless-stopped
207
228
 
229
+ networks:
230
+ default:
231
+ name: ${project}
232
+ labels: *tenant
233
+
208
234
  volumes:
209
- postgres: {}
210
- restate: {}
211
- s2: {}
212
- workspaces: {}
235
+ postgres: { name: ${project}-postgres, labels: *tenant }
236
+ restate: { name: ${project}-restate, labels: *tenant }
237
+ s2-lite: { name: ${project}-s2-lite, labels: *tenant }
238
+ workspaces: { name: ${project}-workspaces, labels: *tenant }
213
239
  `;
214
240
  }
@@ -36,3 +36,13 @@ export interface ComposeService {
36
36
  }
37
37
  /** Parse `docker compose ps --format json` (a JSON array, or one object per line). */
38
38
  export declare function parseComposePs(stdout: string): ComposeService[];
39
+ /** `123.4MiB` (from `docker stats` MemUsage) in bytes; undefined when unreadable. */
40
+ export declare function parseByteSize(text: string): number | undefined;
41
+ /** `812 MB`, `2.1 GB`. */
42
+ export declare function formatBytes(bytes: number): string;
43
+ /**
44
+ * Memory in use per Tenant: the sum over its running containers, found by the label
45
+ * `dev.nylorun.tenant`, from one `docker stats --no-stream`. Undefined when Docker does not
46
+ * answer; a Tenant with no running container is absent.
47
+ */
48
+ export declare function tenantMemory(docker: DockerRunner): Promise<Map<string, number> | undefined>;
@@ -99,3 +99,66 @@ export function parseComposePs(stdout) {
99
99
  };
100
100
  });
101
101
  }
102
+ const UNITS = {
103
+ b: 1,
104
+ kb: 1e3,
105
+ mb: 1e6,
106
+ gb: 1e9,
107
+ tb: 1e12,
108
+ kib: 2 ** 10,
109
+ mib: 2 ** 20,
110
+ gib: 2 ** 30,
111
+ tib: 2 ** 40,
112
+ };
113
+ /** `123.4MiB` (from `docker stats` MemUsage) in bytes; undefined when unreadable. */
114
+ export function parseByteSize(text) {
115
+ const match = /^([\d.]+)\s*([a-z]+)$/i.exec(text.trim());
116
+ const unit = match ? UNITS[match[2].toLowerCase()] : undefined;
117
+ return match && unit ? Number(match[1]) * unit : undefined;
118
+ }
119
+ /** `812 MB`, `2.1 GB`. */
120
+ export function formatBytes(bytes) {
121
+ return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`;
122
+ }
123
+ /**
124
+ * Memory in use per Tenant: the sum over its running containers, found by the label
125
+ * `dev.nylorun.tenant`, from one `docker stats --no-stream`. Undefined when Docker does not
126
+ * answer; a Tenant with no running container is absent.
127
+ */
128
+ export async function tenantMemory(docker) {
129
+ const ps = await docker.run([
130
+ "ps",
131
+ "--filter",
132
+ "label=dev.nylorun.tenant",
133
+ "--format",
134
+ '{{.Names}}\t{{.Label "dev.nylorun.tenant"}}',
135
+ ]);
136
+ if (ps.code !== 0)
137
+ return undefined;
138
+ const owners = new Map(ps.stdout
139
+ .split(/\r?\n/)
140
+ .map((line) => line.trim().split("\t"))
141
+ .filter((cells) => cells.length === 2 && cells[1] !== ""));
142
+ const memory = new Map();
143
+ if (owners.size === 0)
144
+ return memory;
145
+ const stats = await docker.run([
146
+ "stats",
147
+ "--no-stream",
148
+ "--format",
149
+ "{{.Name}}\t{{.MemUsage}}",
150
+ ...owners.keys(),
151
+ ]);
152
+ if (stats.code !== 0)
153
+ return undefined;
154
+ for (const line of stats.stdout.split(/\r?\n/)) {
155
+ const [name, usage] = line.trim().split("\t");
156
+ const tenant = name ? owners.get(name) : undefined;
157
+ const bytes = usage ? parseByteSize(usage.split("/")[0]) : undefined;
158
+ if (tenant && bytes !== undefined)
159
+ memory.set(tenant, (memory.get(tenant) ?? 0) + bytes);
160
+ }
161
+ for (const [tenant, bytes] of memory)
162
+ memory.set(tenant, Math.round(bytes));
163
+ return memory;
164
+ }
@@ -90,6 +90,6 @@ export async function prepareStack(input) {
90
90
  runtimeVersion: input.runtimeVersion,
91
91
  });
92
92
  await writeFileMode(paths.env, renderEnvFile(env), 0o600);
93
- await writeFileMode(paths.compose, renderComposeFile(input.project), 0o644);
93
+ await writeFileMode(paths.compose, renderComposeFile(input.project, input.name), 0o644);
94
94
  return { env, host, adminKey, firstRun };
95
95
  }
@@ -1,3 +1,4 @@
1
+ import type { DockerRunner } from "./docker.js";
1
2
  /**
2
3
  * The machine's local Tenants (tenancy.md §6): one installation per Tenant, each with its own
3
4
  * Host root `~/.nylorun/tenants/<name>/`, Compose project `nylorun-<name>`, ports and volumes.
@@ -48,3 +49,10 @@ export declare function chooseTenantName(base: string, projectDir: string): Prom
48
49
  * volumes with new keys.
49
50
  */
50
51
  export declare function moveStackRoots(base: string, err: (line: string) => void): Promise<void>;
52
+ /**
53
+ * Remove the Studio proxy of nylorun 0.6 (a Caddy container `nylorun-proxy` on its own network,
54
+ * files in `~/.nylorun/proxy/`). Removing the container also detaches it from the Tenants'
55
+ * networks, so their `docker compose down` works again. The directory goes last, once Docker
56
+ * removed both (or never had them), so a Docker that does not answer is retried next time.
57
+ */
58
+ export declare function removeOldProxy(docker: DockerRunner, base: string, err: (line: string) => void): Promise<void>;
@@ -148,3 +148,22 @@ export async function moveStackRoots(base, err) {
148
148
  err(`Moved Tenants ${moved.join(", ")} from ${tilde(stacks)} to ${tilde(tenantsDir(base))}.`);
149
149
  }
150
150
  }
151
+ /**
152
+ * Remove the Studio proxy of nylorun 0.6 (a Caddy container `nylorun-proxy` on its own network,
153
+ * files in `~/.nylorun/proxy/`). Removing the container also detaches it from the Tenants'
154
+ * networks, so their `docker compose down` works again. The directory goes last, once Docker
155
+ * removed both (or never had them), so a Docker that does not answer is retried next time.
156
+ */
157
+ export async function removeOldProxy(docker, base, err) {
158
+ const dir = join(base, "proxy");
159
+ if (!existsSync(dir))
160
+ return;
161
+ const container = await docker.run(["rm", "--force", "nylorun-proxy"]);
162
+ if (container.code !== 0 && !/no such container/i.test(container.stderr))
163
+ return;
164
+ const network = await docker.run(["network", "rm", "nylorun-proxy"]);
165
+ if (network.code !== 0 && !/not found|no such network/i.test(network.stderr))
166
+ return;
167
+ await rm(dir, { recursive: true, force: true });
168
+ err("Removed the Studio proxy of nylorun 0.6 (nylorun-proxy); each Studio is on its own localhost port again (\"nylorun studio\" opens it).");
169
+ }
@@ -1,14 +1,15 @@
1
1
  export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
2
- /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
2
+ /** Studio's published origin: always `localhost` (the cookie's host). */
3
3
  export declare function studioOrigin(port: number): string;
4
4
  /**
5
5
  * Ask the Studio container for a single-use login token with the admin key
6
- * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
6
+ * (`POST /_studio/login-tokens`, Studio §6) on `origin` (its published port)
7
+ * and return the URL to open there.
7
8
  * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
8
9
  */
9
10
  export declare function mintStudioLogin(input: {
10
11
  fetch: FetchLike;
11
- studioPort: number;
12
+ origin: string;
12
13
  adminKey: string;
13
14
  timeoutMs?: number;
14
15
  }): Promise<string>;
@@ -1,15 +1,16 @@
1
1
  import { STACK_CLIENT_HOST } from "./host-files.js";
2
- /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
2
+ /** Studio's published origin: always `localhost` (the cookie's host). */
3
3
  export function studioOrigin(port) {
4
4
  return `http://${STACK_CLIENT_HOST}:${port}`;
5
5
  }
6
6
  /**
7
7
  * Ask the Studio container for a single-use login token with the admin key
8
- * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
8
+ * (`POST /_studio/login-tokens`, Studio §6) on `origin` (its published port)
9
+ * and return the URL to open there.
9
10
  * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
10
11
  */
11
12
  export async function mintStudioLogin(input) {
12
- const origin = studioOrigin(input.studioPort);
13
+ const { origin } = input;
13
14
  const response = await input.fetch(`${origin}/_studio/login-tokens`, {
14
15
  method: "POST",
15
16
  headers: {
@@ -27,7 +28,7 @@ export async function mintStudioLogin(input) {
27
28
  for (const field of ["loginUrl", "url"]) {
28
29
  const value = body[field];
29
30
  if (typeof value === "string" && value !== "") {
30
- // A relative URL is resolved against the published origin.
31
+ // A relative URL is resolved against the origin it was minted on.
31
32
  return new URL(value, origin).toString();
32
33
  }
33
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nylorun",
3
- "version": "0.5.0-beta",
3
+ "version": "0.7.0-beta",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Set up and run local Nylorun Tenants (Runtime and Studio) with Docker Compose",
@@ -41,7 +41,7 @@
41
41
  "nylorun": "dist/cli.js"
42
42
  },
43
43
  "nylorun": {
44
- "runtime": "0.15.1-beta",
45
- "studio": "0.16.0-beta"
44
+ "runtime": "0.15.2-beta",
45
+ "studio": "0.18.0-beta"
46
46
  }
47
47
  }