nylorun 0.8.0-beta → 0.11.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- import type { HostTenant } from "@nylorun/core/contracts";
2
1
  import { type ComposeService, type DockerRunner } from "./docker.js";
3
2
  import type { HarnessMode, SandboxStackEnv } from "./env-file.js";
4
3
  import { type StackPaths } from "./paths.js";
5
4
  import type { PortProbe } from "./ports.js";
6
5
  import { type FetchLike } from "./studio-login.js";
6
+ import { type OperateEndpoint } from "./operator-keys.js";
7
7
  export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2-lite", "rustfs", "gateway", "runtime", "harness", "studio"];
8
8
  export declare const stackUsage: string;
9
9
  export interface StackDeps {
@@ -20,7 +20,7 @@ export interface StackDeps {
20
20
  err(line: string): void;
21
21
  /** Ask a yes/no question on the terminal; undefined when not interactive. */
22
22
  confirm?(question: string): Promise<boolean>;
23
- /** A developer is at a terminal: `start` may open a browser. */
23
+ /** A developer is at a terminal. */
24
24
  interactive?: boolean;
25
25
  /** Open `url` in a browser; false when no browser could be started. */
26
26
  openBrowser(url: string): Promise<boolean>;
@@ -52,10 +52,20 @@ export declare function parseStackFlags(args: readonly string[], allowed: {
52
52
  lists?: readonly string[];
53
53
  aliases?: Record<string, string>;
54
54
  }, usage: string): Flags;
55
- /** The Host's one Tenant as `/v1/admin/status` reports it (no envelope). */
56
- export type StackTenant = Pick<HostTenant, "id" | "name" | "state" | "cause">;
57
- /** Printed by `start` when it does not open Studio itself. */
55
+ /** The Host's one Tenant as `nylorun-operate status --json` reports it. */
56
+ export interface StackTenant {
57
+ /** Null until the Tenant row has been read. */
58
+ id: string | null;
59
+ name: string | null;
60
+ state: "open" | "unavailable";
61
+ /** Why it cannot open (`schema-too-new`, `database-layout-old`, …); `message` says more. */
62
+ cause?: string;
63
+ message?: string;
64
+ }
65
+ /** Printed by `start`, which prints Studio's URL and opens no browser. */
58
66
  export declare const STUDIO_SIGN_IN_HINT = "To sign a browser in to Studio, run \"npx nylorun studio\".";
67
+ /** Printed last by `start`. */
68
+ export declare const HELP_HINT = "Run \"npx nylorun --help\" to see what else nylorun can do.";
59
69
  export interface StackStatus {
60
70
  /** The Tenant's name. */
61
71
  name: string;
@@ -66,13 +76,13 @@ export interface StackStatus {
66
76
  state: "running" | "stopped" | "absent";
67
77
  runtime: {
68
78
  url?: string;
69
- /** The Admin API (operator listener), when the Tenant publishes one. */
70
- adminUrl?: string;
71
79
  healthy: boolean;
80
+ /** `/ready`: the Tenant is open and the Runtime's stores answer. */
81
+ ready: boolean;
72
82
  version?: string;
73
83
  hostId?: string;
74
84
  };
75
- /** The Tenant as the Runtime reports it, while it answers. */
85
+ /** The Tenant as `nylorun-operate status` reports it, while the runtime container runs. */
76
86
  tenant?: StackTenant;
77
87
  studio: {
78
88
  /** `http://localhost:<port>`: also what an embedding app frames (same site as its own localhost). */
@@ -116,9 +126,8 @@ export interface StackEndpoints {
116
126
  home: string;
117
127
  /** `http://localhost:<port>` */
118
128
  runtimeUrl: string;
119
- /** The operator listener (Admin API). */
120
- adminUrl: string;
121
129
  hostId: string;
130
+ /** The admin key (host-credentials.json): it mints Studio logins. */
122
131
  adminKey: string;
123
132
  studioPort: number;
124
133
  /** `http://localhost:<studio port>` */
@@ -128,14 +137,32 @@ export interface StackEndpoints {
128
137
  /** This call started the Runtime (it was not running before). */
129
138
  started: boolean;
130
139
  }
131
- /** The running Tenant's API, reached as the Project's derived principal (`nylorun sandbox`). */
140
+ /** The running Tenant's APIs and their keys (`nylorun sandbox`, `nylorun mcp`). */
132
141
  export interface TenantApi {
133
142
  /** The Tenant's name. */
134
143
  name: string;
135
144
  runtimeUrl: string;
136
- /** The `project` derived principal's key, derived from the admin key; never stored. */
145
+ /**
146
+ * The linked project's keys (`.nylorun/credentials.json`) when both reach the Tenant, else
147
+ * the keys `cli` and `cli-management` the Host root keeps (`cli-credentials.json`).
148
+ */
137
149
  applicationKey: string;
150
+ managementKey: string;
138
151
  }
152
+ /** The selected Tenant's `nylorun-operate` while it runs: `nylorun key` and `runningTenantApi`. */
153
+ export interface RunningOperate extends OperateEndpoint {
154
+ /** The Tenant's name. */
155
+ name: string;
156
+ runtimeUrl: string;
157
+ paths: StackPaths;
158
+ }
159
+ /**
160
+ * The selected Tenant's `nylorun-operate` while it runs; exit 3 when it is not running, 7 when
161
+ * it is not open.
162
+ */
163
+ export declare function runningOperate(deps: StackDeps, options?: {
164
+ name?: string;
165
+ }): Promise<RunningOperate>;
139
166
  /**
140
167
  * The selected Tenant's API while it runs: commands that read or change the Tenant's data never
141
168
  * start it. Exit 3 when it is not running, 7 when it is not open.
@@ -1,10 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { basename, resolve } from "node:path";
4
- import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
4
+ import { compareVersions } from "@nylorun/core/compatibility";
5
5
  import { CliError } from "../errors.js";
6
- import { deriveTenantKey, PROJECT_PRINCIPAL_ID } from "../project/derived-key.js";
7
- import { readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
6
+ import { credentialsPath, readCredentialsFile, readProjectCredentials, readProjectLink, writeProjectCredentials, writeProjectLink, } from "../project/link.js";
8
7
  import { findProjectRoot } from "../project/root.js";
9
8
  import { seedTenant } from "../project/seed.js";
10
9
  import { readTelemetry, STUDIO_ANALYTICS_ID, TELEMETRY_NOTICE, telemetryDecision, writeTelemetry, } from "../telemetry.js";
@@ -15,6 +14,7 @@ import { stackPaths } from "./paths.js";
15
14
  import { prepareStack, readStackEnv } from "./prepare.js";
16
15
  import { assertTenantName, chooseTenantName, DEFAULT_TENANT, defaultNylorunRoot, listTenants, moveStackRoots, readTenantRecord, removeOldProxy, sanitizeTenantName, tenantRoot, tenantsDir, writeTenantRecord, } from "./stacks.js";
17
16
  import { mintStudioLogin, studioOrigin } from "./studio-login.js";
17
+ import { CLI_KEY_ID, hostKey, keyAuthenticates, PROJECT_KEY_ID, } from "./operator-keys.js";
18
18
  export const STACK_SERVICES = [
19
19
  "postgres",
20
20
  "restate",
@@ -30,10 +30,10 @@ const CORE_SERVICES = ["postgres", "restate", "s2-lite", "rustfs", "gateway", "r
30
30
  function coreServices(harness) {
31
31
  return CORE_SERVICES.filter((service) => service !== "harness" || harness === "remote");
32
32
  }
33
- export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]
33
+ export const stackUsage = ` up|start [--tenant <name>] [--no-link] [--no-studio] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]
34
34
  start the project's Tenant, creating it and the Project link (.nylorun/link.json,
35
- credentials.json) on the first run; print the Runtime and Studio URLs and open Studio
36
- signed in (in a terminal). --tenant attaches to (or creates) a named Tenant; --no-link
35
+ credentials.json) on the first run; print the Runtime and Studio URLs (it opens no
36
+ browser). --tenant attaches to (or creates) a named Tenant; --no-link
37
37
  starts the default Tenant (or --tenant's) without linking the current directory;
38
38
  --restate-ui (or NYLORUN_RESTATE_UI=1) publishes Restate's UI on loopback for debugging
39
39
  down|stop [--tenant <name> | --all] stop the Tenant's containers (--all: every Tenant's); keep volumes
@@ -147,6 +147,13 @@ function composeArgs(ctx, ...args) {
147
147
  ...args,
148
148
  ];
149
149
  }
150
+ /** `nylorun-operate` in the Tenant's runtime container (`docker compose exec`), for its keys. */
151
+ function operateEndpoint(ctx) {
152
+ return {
153
+ fetch: ctx.deps.fetch,
154
+ operate: (args) => ctx.deps.docker.run(composeArgs(ctx, "exec", "-T", "runtime", "nylorun-operate", ...args)),
155
+ };
156
+ }
150
157
  function requireStackFiles(ctx) {
151
158
  if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
152
159
  throw new CliError(`No Tenant ${ctx.name} under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
@@ -221,34 +228,21 @@ async function waitForHealth(ctx, runtimeUrl, hostId) {
221
228
  await sleep(ctx.deps.pollMs ?? 500);
222
229
  }
223
230
  }
224
- /** `/v1/admin/status` on the operator listener; undefined when it does not answer. */
225
- async function fetchAdminStatus(deps, adminUrl, adminKey) {
226
- if (!adminKey)
227
- return undefined;
231
+ /** The Runtime's `/ready` (503 while not ready, same body); undefined when it does not answer. */
232
+ async function fetchReady(deps, runtimeUrl) {
228
233
  try {
229
- const response = await deps.fetch(`${adminUrl}/v1/admin/status`, {
230
- headers: {
231
- authorization: `Bearer ${adminKey}`,
232
- [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
233
- accept: "application/json",
234
- },
234
+ const response = await deps.fetch(`${runtimeUrl}/ready`, {
235
+ headers: { accept: "application/json" },
235
236
  signal: AbortSignal.timeout(3000),
236
237
  redirect: "error",
237
238
  });
238
- if (!response.ok)
239
- return undefined;
240
239
  const body = (await response.json());
241
- const tenant = body.tenant;
242
- if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
240
+ if (body.status !== "ready" && body.status !== "not_ready")
243
241
  return undefined;
244
- const harness = body.aggregate?.harness;
242
+ const harness = body.harness;
245
243
  return {
246
- tenant: {
247
- id: typeof tenant.id === "string" ? tenant.id : null,
248
- name: typeof tenant.name === "string" ? tenant.name : null,
249
- state: tenant.state,
250
- ...(tenant.cause ? { cause: tenant.cause } : {}),
251
- },
244
+ ready: body.status === "ready",
245
+ tenant: body.checks?.tenant === true,
252
246
  ...((harness?.mode === "remote" || harness?.mode === "in-process") &&
253
247
  typeof harness.connected === "number"
254
248
  ? { harness: { mode: harness.mode, connected: harness.connected } }
@@ -259,32 +253,59 @@ async function fetchAdminStatus(deps, adminUrl, adminKey) {
259
253
  return undefined;
260
254
  }
261
255
  }
262
- /** `/v1/admin/status`'s Tenant on the operator listener; undefined when it does not answer. */
263
- async function fetchTenant(deps, adminUrl, adminKey) {
264
- return (await fetchAdminStatus(deps, adminUrl, adminKey))?.tenant;
256
+ /**
257
+ * `nylorun-operate status --json` in the runtime container (exit 0 open, 2 not open): the
258
+ * Tenant's id and name, and why it cannot open. Undefined when it does not answer.
259
+ */
260
+ async function operateStatus(ctx) {
261
+ const result = await operateEndpoint(ctx).operate(["status", "--json"]);
262
+ if (result.code !== 0 && result.code !== 2)
263
+ return undefined;
264
+ let tenant;
265
+ try {
266
+ tenant = JSON.parse(result.stdout).tenant;
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ if (!tenant || (tenant.state !== "open" && tenant.state !== "unavailable"))
272
+ return undefined;
273
+ return {
274
+ id: typeof tenant.id === "string" ? tenant.id : null,
275
+ name: typeof tenant.name === "string" ? tenant.name : null,
276
+ state: tenant.state,
277
+ ...(typeof tenant.cause === "string" ? { cause: tenant.cause } : {}),
278
+ ...(typeof tenant.message === "string" ? { message: tenant.message } : {}),
279
+ };
265
280
  }
266
- /** Why the Tenant is unavailable, with the repair the Runtime names. */
281
+ /** Why the Tenant is unavailable, as `nylorun-operate status` names it. */
267
282
  function tenantCauseMessage(ctx, tenant) {
268
- const cause = tenant.cause;
269
- const hint = cause.code === "schema-too-new"
283
+ const hint = tenant.cause === "schema-too-new"
270
284
  ? ` This nylorun pins Runtime ${ctx.deps.runtimeVersion}, older than the Tenant's database: update nylorun (npx nylorun@latest start).`
271
285
  : "";
272
- return `Tenant ${ctx.name} is unavailable (${cause.code}): ${cause.message} ${cause.repair}${hint} See "nylorun logs runtime".`;
286
+ const message = tenant.message ? `: ${tenant.message.replace(/\.?$/, ".")}` : ".";
287
+ return `Tenant ${ctx.name} is unavailable (${tenant.cause})${message}${hint} See "nylorun logs runtime".`;
273
288
  }
274
289
  /**
275
- * Wait until the Tenant is open: the Runtime creates it on the first start and opens
276
- * it on later ones. A Tenant that could not be opened has a cause; report it.
290
+ * Wait until the Tenant is open (`/ready`'s `tenant` check): the Runtime creates it on the
291
+ * first start and opens it on later ones. Then `nylorun-operate status` gives its id. While it
292
+ * is not open, `nylorun-operate status` is asked now and then: it names a cause only when the
293
+ * Tenant cannot open (`schema-too-new`, `database-layout-old`), which ends the wait.
277
294
  */
278
- async function waitForTenant(ctx, adminUrl, adminKey) {
295
+ async function waitForTenant(ctx, runtimeUrl) {
279
296
  const deadline = Date.now() + (ctx.deps.healthTimeoutMs ?? 60_000);
280
- for (;;) {
281
- const tenant = await fetchTenant(ctx.deps, adminUrl, adminKey);
282
- if (tenant?.state === "open" && tenant.id)
283
- return { ...tenant, id: tenant.id };
284
- if (tenant?.cause)
285
- throw new CliError(tenantCauseMessage(ctx, tenant), 7);
286
- if (Date.now() > deadline)
287
- 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);
297
+ for (let poll = 1;; poll += 1) {
298
+ const open = (await fetchReady(ctx.deps, runtimeUrl))?.tenant === true;
299
+ const late = Date.now() > deadline;
300
+ if (open || late || poll % 10 === 0) {
301
+ const tenant = await operateStatus(ctx);
302
+ if (tenant?.state === "open" && tenant.id)
303
+ return { ...tenant, id: tenant.id };
304
+ if (!open && tenant?.cause)
305
+ throw new CliError(tenantCauseMessage(ctx, tenant), 7);
306
+ if (late)
307
+ throw new CliError(`Tenant ${ctx.name} did not open (${runtimeUrl}/ready: ${open ? "open, but nylorun-operate status did not answer" : "not open"})${tenant?.message ? `: ${tenant.message.replace(/\.$/, "")}` : ""}. See "nylorun status" and "nylorun logs runtime".`, 7);
308
+ }
288
309
  await sleep(ctx.deps.pollMs ?? 500);
289
310
  }
290
311
  }
@@ -353,7 +374,6 @@ async function tenantPorts(base, except) {
353
374
  const persisted = await readStackEnv(paths);
354
375
  for (const port of [
355
376
  persisted?.runtimePort,
356
- persisted?.adminPort,
357
377
  persisted?.studioPort,
358
378
  persisted?.restatePort,
359
379
  ])
@@ -392,9 +412,6 @@ async function bringUp(ctx, options) {
392
412
  runtimeVersion: overridden ? undefined : deps.runtimeVersion,
393
413
  ports: deps.ports,
394
414
  reserved: await reservedPorts(ctx),
395
- ...(deps.env.NYLORUN_DERIVED_PRINCIPALS?.trim()
396
- ? { derivedPrincipals: deps.env.NYLORUN_DERIVED_PRINCIPALS }
397
- : {}),
398
415
  ...(options.studioEmbedOrigins ? { studioEmbedOrigins: options.studioEmbedOrigins } : {}),
399
416
  ...(analytics ? { studioAnalyticsId: STUDIO_ANALYTICS_ID } : {}),
400
417
  ...(options.sandboxes !== undefined ? { sandboxes: options.sandboxes } : {}),
@@ -411,14 +428,13 @@ async function bringUp(ctx, options) {
411
428
  if (prepared.firstRun)
412
429
  deps.err(`Created Tenant ${ctx.name} under ${ctx.paths.root} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
413
430
  const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
414
- const adminUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.adminPort}`;
415
431
  const up = await deps.docker.stream(
416
432
  // --remove-orphans: a service this start's file no longer has (the harness after a rollback
417
433
  // to NYLORUN_HARNESS=in-process, sandboxes after disable) is removed, not left running.
418
434
  composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", "--remove-orphans", ...coreServices(prepared.env.harness)));
419
435
  if (up !== 0) {
420
- // A Runtime whose Tenant cannot open fails readiness; its status names the cause.
421
- const tenant = await fetchTenant(deps, adminUrl, prepared.adminKey);
436
+ // A Runtime whose Tenant cannot open fails readiness; nylorun-operate names the cause.
437
+ const tenant = await operateStatus(ctx);
422
438
  if (tenant?.cause)
423
439
  throw new CliError(tenantCauseMessage(ctx, tenant), 7);
424
440
  throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime", "nylorun logs gateway", "nylorun logs harness" and "nylorun status".`, 7);
@@ -439,7 +455,6 @@ async function bringUp(ctx, options) {
439
455
  }
440
456
  return {
441
457
  runtimeUrl,
442
- adminUrl,
443
458
  hostId: prepared.host.hostId,
444
459
  studioPort: prepared.env.studioPort,
445
460
  studioUrl: studioOrigin(prepared.env.studioPort),
@@ -471,12 +486,10 @@ async function tryStudioLogin(ctx, studioPort, adminKey) {
471
486
  deps.err(`Warning: Studio at ${origin} is not reachable for a login (${lastError}).`);
472
487
  return undefined;
473
488
  }
474
- /** Printed by `start` when it does not open Studio itself. */
489
+ /** Printed by `start`, which prints Studio's URL and opens no browser. */
475
490
  export const STUDIO_SIGN_IN_HINT = 'To sign a browser in to Studio, run "npx nylorun studio".';
476
- /** `start` opens Studio only for a developer at a terminal, outside CI. */
477
- function opensBrowser(deps, flags) {
478
- return Boolean(deps.interactive) && !deps.env.CI && !flags.booleans.has("--no-open");
479
- }
491
+ /** Printed last by `start`. */
492
+ export const HELP_HINT = 'Run "npx nylorun --help" to see what else nylorun can do.';
480
493
  /**
481
494
  * Open a Studio login in the browser, or print it when no browser starts.
482
495
  * The login URL carries a single-use token, so it is printed only as a fallback.
@@ -487,13 +500,18 @@ async function openLogin(ctx, login) {
487
500
  }
488
501
  /**
489
502
  * Link the project to the Tenant: `.nylorun/link.json` (format 3) and
490
- * `.nylorun/credentials.json` with the key of the derived principal `project`. The link is
491
- * rewritten only when it is older, or the Tenant's name, URL, Host or id changed; a new link
492
- * seeds the Tenant from the project's `.env`.
503
+ * `.nylorun/credentials.json`. The link is rewritten only when it is older, or the Tenant's
504
+ * name, URL, Host or id changed; a new link seeds the Tenant from the project's `.env`.
505
+ *
506
+ * The credentials file is kept while both its keys still reach the Tenant (one authenticated
507
+ * read each). Otherwise it gets the keys `project` (application) and `project-management`
508
+ * (management): the ones the Host root keeps for the projects linked to this Tenant
509
+ * (`project-credentials.json`), or new ones put through `nylorun-operate`. Every checkout linked
510
+ * to the Tenant shares them, so linking one never rotates another's keys. A file from before
511
+ * management keys keeps its application key and gains the management key.
493
512
  */
494
513
  async function linkProject(ctx, started, tenantId) {
495
514
  const { deps, projectDir, link } = ctx;
496
- const applicationKey = deriveTenantKey(started.adminKey, tenantId, PROJECT_PRINCIPAL_ID);
497
515
  const fresh = link?.format !== 3 ||
498
516
  link.tenant !== ctx.name ||
499
517
  link.hostUrl !== started.runtimeUrl ||
@@ -506,14 +524,28 @@ async function linkProject(ctx, started, tenantId) {
506
524
  hostId: started.hostId,
507
525
  tenantId,
508
526
  });
509
- const credentials = await readProjectCredentials(projectDir);
510
- if (fresh ||
511
- credentials?.applicationKey !== applicationKey ||
512
- credentials.principalId !== PROJECT_PRINCIPAL_ID)
513
- await writeProjectCredentials(projectDir, {
514
- applicationKey,
515
- principalId: PROJECT_PRINCIPAL_ID,
527
+ const reaches = (key) => keyAuthenticates(deps.fetch, started.runtimeUrl, key);
528
+ const existing = await readProjectCredentials(projectDir);
529
+ const kept = existing && (await reaches(existing.applicationKey)) ? existing : undefined;
530
+ let credentials = kept;
531
+ if (!kept?.management ||
532
+ // A project key from before operator keys becomes the one the Host root keeps.
533
+ (kept.principalId === PROJECT_KEY_ID &&
534
+ (await readCredentialsFile(ctx.paths.projectCredentials))?.applicationKey !== kept.applicationKey) ||
535
+ !(await reaches(kept.management.key))) {
536
+ const keys = await hostKey({
537
+ endpoint: operateEndpoint(ctx),
538
+ runtimeUrl: started.runtimeUrl,
539
+ id: PROJECT_KEY_ID,
540
+ file: ctx.paths.projectCredentials,
541
+ lock: ctx.paths.keysLock,
542
+ ...(kept?.principalId === PROJECT_KEY_ID ? { adopt: kept } : {}),
516
543
  });
544
+ credentials = kept ? { ...kept, management: keys.management } : keys;
545
+ await writeProjectCredentials(projectDir, credentials);
546
+ if (existing && !kept && !fresh)
547
+ deps.err(`Replaced the project's key, which no longer reaches Tenant ${ctx.name} (.nylorun/credentials.json).`);
548
+ }
517
549
  if (!fresh)
518
550
  return;
519
551
  deps.err(`Linked ${projectDir} to Tenant ${ctx.name} (.nylorun/link.json, .nylorun/credentials.json).`);
@@ -521,7 +553,7 @@ async function linkProject(ctx, started, tenantId) {
521
553
  const seeded = await seedTenant({
522
554
  fetch: deps.fetch,
523
555
  runtimeUrl: started.runtimeUrl,
524
- applicationKey,
556
+ managementKey: credentials.management.key,
525
557
  projectRoot: projectDir,
526
558
  });
527
559
  const set = [...seeded.applied, ...(seeded.model ? [`model ${seeded.model}`] : [])];
@@ -532,11 +564,12 @@ async function linkProject(ctx, started, tenantId) {
532
564
  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".`);
533
565
  }
534
566
  }
535
- const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--no-open] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]";
567
+ const START_USAGE = "nylorun start [--tenant <name>] [--no-link] [--no-studio] [--allow-downgrade] [--studio-embed-origin <origin>]... [--studio-embed-origin-reset] [--restate-ui]";
536
568
  async function start(deps, args) {
537
569
  const flags = parseStackFlags(args, {
538
570
  booleans: [
539
571
  "--no-studio",
572
+ // Accepted and ignored: start no longer opens a browser.
540
573
  "--no-open",
541
574
  "--no-link",
542
575
  "--allow-downgrade",
@@ -564,7 +597,7 @@ async function start(deps, args) {
564
597
  ? { studioEmbedOrigins: { add: embedOrigins, reset: resetEmbed } }
565
598
  : {}),
566
599
  });
567
- const tenant = await waitForTenant(ctx, started.adminUrl, started.adminKey);
600
+ const tenant = await waitForTenant(ctx, started.runtimeUrl);
568
601
  if (ctx.projectDir)
569
602
  await linkProject({ ...ctx, projectDir: ctx.projectDir }, started, tenant.id);
570
603
  deps.out(`Tenant ${ctx.name} (${tenant.id})`);
@@ -573,15 +606,10 @@ async function start(deps, args) {
573
606
  deps.out(`Restate ${started.restateUrl} (UI and admin, unauthenticated; for debugging)`);
574
607
  if (started.studioStarted) {
575
608
  deps.out(`Studio ${started.studioUrl}`);
576
- if (opensBrowser(deps, flags)) {
577
- const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
578
- if (login)
579
- await openLogin(ctx, withNext(login, tenantStudioPath(tenant.id)));
580
- }
581
- else
582
- deps.err(STUDIO_SIGN_IN_HINT);
609
+ deps.err(STUDIO_SIGN_IN_HINT);
583
610
  }
584
611
  await alsoRunning(ctx);
612
+ deps.err(HELP_HINT);
585
613
  return 0;
586
614
  }
587
615
  /** After `start`: the machine's other running Tenants and their memory, on stderr. */
@@ -649,7 +677,7 @@ async function stackStatus(ctx) {
649
677
  project: ctx.project,
650
678
  home: ctx.paths.root,
651
679
  state: "absent",
652
- runtime: { healthy: false },
680
+ runtime: { healthy: false, ready: false },
653
681
  studio: { state: "absent" },
654
682
  restate: { published: false },
655
683
  gateway: { state: "absent", healthy: false },
@@ -667,14 +695,11 @@ async function stackStatus(ctx) {
667
695
  const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
668
696
  const host = await readHostConfig(ctx.paths);
669
697
  const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
670
- // The Admin API answers on the operator port; an older Host root has only the Runtime port.
671
- const adminUrl = persisted.adminPort
672
- ? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
673
- : runtimeUrl;
674
- const answered = healthy && adminUrl
675
- ? await fetchAdminStatus(ctx.deps, adminUrl, await readAdminKey(ctx.paths))
698
+ const ready = healthy && runtimeUrl ? await fetchReady(ctx.deps, runtimeUrl) : undefined;
699
+ // nylorun-operate answers while the container runs, also when the Tenant cannot open.
700
+ const tenant = services.some((s) => s.service === "runtime" && s.state === "running")
701
+ ? await operateStatus(ctx)
676
702
  : undefined;
677
- const tenant = answered?.tenant;
678
703
  const studio = services.find((s) => s.service === "studio");
679
704
  const gateway = services.find((s) => s.service === "gateway");
680
705
  const harness = services.find((s) => s.service === "harness");
@@ -684,8 +709,8 @@ async function stackStatus(ctx) {
684
709
  state: services.some((s) => s.state === "running") ? "running" : "stopped",
685
710
  runtime: {
686
711
  ...(runtimeUrl ? { url: runtimeUrl } : {}),
687
- ...(persisted.adminPort && adminUrl ? { adminUrl } : {}),
688
712
  healthy,
713
+ ready: ready?.ready ?? false,
689
714
  ...(health?.version ? { version: health.version } : {}),
690
715
  ...(health?.hostId ? { hostId: health.hostId } : {}),
691
716
  },
@@ -705,10 +730,10 @@ async function stackStatus(ctx) {
705
730
  healthy: isUp(services, "gateway"),
706
731
  },
707
732
  harness: {
708
- mode: answered?.harness?.mode ?? harnessMode,
733
+ mode: ready?.harness?.mode ?? harnessMode,
709
734
  state: harness ? [harness.state, harness.health].filter(Boolean).join(", ") : "absent",
710
735
  healthy: isUp(services, "harness"),
711
- ...(answered?.harness ? { connected: answered.harness.connected } : {}),
736
+ ...(ready?.harness ? { connected: ready.harness.connected } : {}),
712
737
  },
713
738
  services,
714
739
  };
@@ -722,9 +747,8 @@ function describeTenant(tenant) {
722
747
  const who = `${tenant.id ?? "(no id yet)"}${tenant.name ? ` (${tenant.name})` : ""}`;
723
748
  if (tenant.state === "open")
724
749
  return `${who} open`;
725
- return tenant.cause
726
- ? `${who} unavailable: ${tenant.cause.code}: ${tenant.cause.message} ${tenant.cause.repair}`
727
- : `${who} unavailable (opening)`;
750
+ const why = [tenant.cause, tenant.message].filter(Boolean).join(": ");
751
+ return `${who} unavailable${why ? `: ${why}` : " (opening)"}`;
728
752
  }
729
753
  async function status(deps, args) {
730
754
  const usage = "nylorun status [--tenant <name>] [--json]";
@@ -745,11 +769,9 @@ async function status(deps, args) {
745
769
  if (result.tenant)
746
770
  out(`Tenant id ${describeTenant(result.tenant)}`);
747
771
  const runtimeDetail = result.runtime.healthy
748
- ? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
772
+ ? `healthy, ${result.runtime.ready ? "ready" : "not ready"}, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}`
749
773
  : "not answering";
750
774
  out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
751
- if (result.runtime.adminUrl)
752
- out(`Admin API ${result.runtime.adminUrl} (operators only, never proxied)`);
753
775
  out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
754
776
  if (result.studio.embedOrigins?.length)
755
777
  out(`Embeds ${result.studio.embedOrigins.join(" ")} (may show Studio in a frame)`);
@@ -860,7 +882,6 @@ async function ls(deps, args) {
860
882
  ...(persisted.studioPort ? { studioUrl: studioOrigin(persisted.studioPort) } : {}),
861
883
  ports: {
862
884
  ...(persisted.runtimePort ? { runtime: persisted.runtimePort } : {}),
863
- ...(persisted.adminPort ? { admin: persisted.adminPort } : {}),
864
885
  ...(persisted.studioPort ? { studio: persisted.studioPort } : {}),
865
886
  ...(persisted.restatePort ? { restate: persisted.restatePort } : {}),
866
887
  },
@@ -940,9 +961,6 @@ async function runningStack(ctx, options) {
940
961
  name: ctx.name,
941
962
  home: ctx.paths.root,
942
963
  runtimeUrl,
943
- adminUrl: persisted.adminPort
944
- ? `http://${STACK_CLIENT_HOST}:${persisted.adminPort}`
945
- : runtimeUrl,
946
964
  hostId: host.hostId,
947
965
  adminKey,
948
966
  studioPort: persisted.studioPort,
@@ -962,7 +980,6 @@ async function ensureSelected(ctx, options) {
962
980
  name: ctx.name,
963
981
  home: ctx.paths.root,
964
982
  runtimeUrl: started.runtimeUrl,
965
- adminUrl: started.adminUrl,
966
983
  hostId: started.hostId,
967
984
  adminKey: started.adminKey,
968
985
  studioPort: started.studioPort,
@@ -971,25 +988,60 @@ async function ensureSelected(ctx, options) {
971
988
  started: !runtimeWasUp,
972
989
  };
973
990
  }
974
- /**
975
- * The selected Tenant's API while it runs: commands that read or change the Tenant's data never
976
- * start it. Exit 3 when it is not running, 7 when it is not open.
977
- */
978
- export async function runningTenantApi(deps, options = {}) {
991
+ async function runningSelected(deps, options) {
979
992
  const ctx = await selectStack(deps, options);
980
993
  requireStackFiles(ctx);
981
994
  await dockerPreflight(deps.docker);
982
995
  const running = await runningStack(ctx, { studio: false });
983
996
  if (!running)
984
997
  throw new CliError(`Tenant ${ctx.name} is not running. Run "nylorun start" first.`, 3);
985
- const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
986
- if (!tenant?.id || tenant.state !== "open")
998
+ if ((await fetchReady(deps, running.runtimeUrl))?.tenant !== true)
987
999
  throw new CliError(`Tenant ${ctx.name} is not open. See "nylorun status".`, 7);
988
1000
  return {
1001
+ ctx,
1002
+ running: {
1003
+ name: ctx.name,
1004
+ runtimeUrl: running.runtimeUrl,
1005
+ paths: ctx.paths,
1006
+ ...operateEndpoint(ctx),
1007
+ },
1008
+ };
1009
+ }
1010
+ /**
1011
+ * The selected Tenant's `nylorun-operate` while it runs; exit 3 when it is not running, 7 when
1012
+ * it is not open.
1013
+ */
1014
+ export async function runningOperate(deps, options = {}) {
1015
+ return (await runningSelected(deps, options)).running;
1016
+ }
1017
+ /**
1018
+ * The selected Tenant's API while it runs: commands that read or change the Tenant's data never
1019
+ * start it. Exit 3 when it is not running, 7 when it is not open.
1020
+ */
1021
+ export async function runningTenantApi(deps, options = {}) {
1022
+ const { ctx, running } = await runningSelected(deps, options);
1023
+ const api = (applicationKey, managementKey) => ({
989
1024
  name: ctx.name,
990
1025
  runtimeUrl: running.runtimeUrl,
991
- applicationKey: deriveTenantKey(running.adminKey, tenant.id, PROJECT_PRINCIPAL_ID),
992
- };
1026
+ applicationKey,
1027
+ managementKey,
1028
+ });
1029
+ const reaches = (key) => keyAuthenticates(deps.fetch, running.runtimeUrl, key);
1030
+ if (ctx.projectDir && ctx.link?.format === 3 && ctx.link.tenant === ctx.name) {
1031
+ const project = await readCredentialsFile(credentialsPath(ctx.projectDir));
1032
+ if (project?.management &&
1033
+ (await reaches(project.applicationKey)) &&
1034
+ (await reaches(project.management.key)))
1035
+ return api(project.applicationKey, project.management.key);
1036
+ }
1037
+ const keys = await hostKey({
1038
+ endpoint: running,
1039
+ runtimeUrl: running.runtimeUrl,
1040
+ id: CLI_KEY_ID,
1041
+ file: ctx.paths.cliCredentials,
1042
+ lock: ctx.paths.keysLock,
1043
+ });
1044
+ return api(keys.applicationKey, keys.management.key);
993
1045
  }
994
1046
  /**
995
1047
  * Start the selected Tenant unless it is already running (`nylorun studio`): the
@@ -1067,7 +1119,7 @@ async function studio(deps, args, options = {}) {
1067
1119
  // Studio serves the one Tenant; the link's Tenant id stands in while it is opening.
1068
1120
  let next = options.next;
1069
1121
  if (next === undefined) {
1070
- const tenant = await fetchTenant(deps, running.adminUrl, running.adminKey);
1122
+ const tenant = await operateStatus(ctx);
1071
1123
  const linked = ctx.link?.tenant === ctx.name ? ctx.link.tenantId : undefined;
1072
1124
  const tenantId = tenant?.id ?? linked;
1073
1125
  if (tenantId)