nylorun 0.0.0-bootstrap → 0.1.1-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 (44) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +192 -0
  3. package/README.md +98 -0
  4. package/dist/baseline.d.ts +2 -0
  5. package/dist/baseline.js +10 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +78 -0
  8. package/dist/doctor.d.ts +23 -0
  9. package/dist/doctor.js +76 -0
  10. package/dist/errors.d.ts +5 -0
  11. package/dist/errors.js +9 -0
  12. package/dist/home.d.ts +6 -0
  13. package/dist/home.js +15 -0
  14. package/dist/project/link.d.ts +6 -0
  15. package/dist/project/link.js +21 -0
  16. package/dist/project/root.d.ts +7 -0
  17. package/dist/project/root.js +44 -0
  18. package/dist/stack/commands.d.ts +103 -0
  19. package/dist/stack/commands.js +478 -0
  20. package/dist/stack/compose-file.d.ts +14 -0
  21. package/dist/stack/compose-file.js +128 -0
  22. package/dist/stack/docker.d.ts +38 -0
  23. package/dist/stack/docker.js +101 -0
  24. package/dist/stack/env-file.d.ts +32 -0
  25. package/dist/stack/env-file.js +93 -0
  26. package/dist/stack/host-files.d.ts +36 -0
  27. package/dist/stack/host-files.js +93 -0
  28. package/dist/stack/images.d.ts +22 -0
  29. package/dist/stack/images.js +28 -0
  30. package/dist/stack/index.d.ts +13 -0
  31. package/dist/stack/index.js +75 -0
  32. package/dist/stack/paths.d.ts +20 -0
  33. package/dist/stack/paths.js +18 -0
  34. package/dist/stack/ports.d.ts +18 -0
  35. package/dist/stack/ports.js +45 -0
  36. package/dist/stack/prepare.d.ts +28 -0
  37. package/dist/stack/prepare.js +65 -0
  38. package/dist/stack/restate-identity.d.ts +28 -0
  39. package/dist/stack/restate-identity.js +92 -0
  40. package/dist/stack/studio-login.d.ts +14 -0
  41. package/dist/stack/studio-login.js +37 -0
  42. package/dist/stack/versions.d.ts +9 -0
  43. package/dist/stack/versions.js +28 -0
  44. package/package.json +43 -3
@@ -0,0 +1,103 @@
1
+ import { type ComposeService, type DockerRunner } from "./docker.js";
2
+ import type { PortProbe } from "./ports.js";
3
+ import { type FetchLike } from "./studio-login.js";
4
+ export declare const STACK_SERVICES: readonly ["postgres", "restate", "s2", "runtime", "studio"];
5
+ export declare const stackUsage: string;
6
+ export interface StackDeps {
7
+ /** Environment snapshot (NYLORUN_HOME, image overrides, NYLORUN_STACK_PROJECT). */
8
+ env: Readonly<Record<string, string | undefined>>;
9
+ docker: DockerRunner;
10
+ fetch: FetchLike;
11
+ ports: PortProbe;
12
+ uid: number;
13
+ gid: number;
14
+ runtimeVersion: string;
15
+ studioVersion: string;
16
+ out(line: string): void;
17
+ err(line: string): void;
18
+ /** Ask a yes/no question on the terminal; undefined when not interactive. */
19
+ confirm?(question: string): Promise<boolean>;
20
+ openBrowser(url: string): Promise<void>;
21
+ /** Is this process id alive? (launcher-managed Runtime detection) */
22
+ pidAlive(pid: number): boolean;
23
+ /** Delay between health polls; tests shorten it. */
24
+ pollMs?: number;
25
+ /** How long `start` waits for the Runtime's /health after Compose. */
26
+ healthTimeoutMs?: number;
27
+ /** How long a Studio login is retried before warning. */
28
+ loginTimeoutMs?: number;
29
+ }
30
+ export declare function stackProject(env: StackDeps["env"]): string;
31
+ interface Flags {
32
+ rest: string[];
33
+ booleans: Set<string>;
34
+ values: Map<string, string>;
35
+ }
36
+ export declare function parseStackFlags(args: readonly string[], allowed: {
37
+ booleans?: readonly string[];
38
+ values?: readonly string[];
39
+ aliases?: Record<string, string>;
40
+ }, usage: string): Flags;
41
+ /** Printed by `start` while the Host has no Tenant: nylorun never creates one. */
42
+ export declare const TENANT_HINT = "No Tenant yet. Create one in Studio, or run `npx @nylorun/cli tenant create` in your project.";
43
+ export interface StackStatus {
44
+ project: string;
45
+ home: string;
46
+ state: "running" | "stopped" | "absent";
47
+ runtime: {
48
+ url?: string;
49
+ healthy: boolean;
50
+ version?: string;
51
+ hostId?: string;
52
+ tenants?: number;
53
+ };
54
+ studio: {
55
+ url?: string;
56
+ state: string;
57
+ };
58
+ restate: {
59
+ url?: string;
60
+ };
61
+ services: ComposeService[];
62
+ }
63
+ /** What `nylorun status` reports, as data (`nylorun doctor`). */
64
+ export declare function readStackStatus(deps: StackDeps): Promise<StackStatus>;
65
+ /** The running stack as clients reach it. */
66
+ export interface StackEndpoints {
67
+ /** The Host root (`NYLORUN_HOME` or `~/.nylorun`). */
68
+ home: string;
69
+ /** `http://localhost:<port>` */
70
+ runtimeUrl: string;
71
+ hostId: string;
72
+ adminKey: string;
73
+ studioPort: number;
74
+ /** Studio is running and healthy. */
75
+ studioUp: boolean;
76
+ /** This call started the Runtime (it was not running before). */
77
+ started: boolean;
78
+ }
79
+ /**
80
+ * Start the stack unless it is already running (`nylorun studio`): the
81
+ * `start` code path without its own output. Compose progress
82
+ * still streams, since the first run pulls images.
83
+ */
84
+ export declare function ensureStack(deps: StackDeps, options: {
85
+ studio: boolean;
86
+ }): Promise<StackEndpoints>;
87
+ /** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
88
+ export declare function withNext(loginUrl: string, next: string | undefined): string;
89
+ /** The Studio page for one Tenant, as a login `next` path. */
90
+ export declare function tenantStudioPath(tenantId: string): string;
91
+ /**
92
+ * Mint a fresh Studio login URL on a running stack, landing on `next`.
93
+ * Undefined (after a warning) when Studio does not answer.
94
+ */
95
+ export declare function studioLoginUrl(deps: StackDeps, stack: Pick<StackEndpoints, "studioPort" | "adminKey">, next?: string): Promise<string | undefined>;
96
+ /** `nylorun studio`, landing on `next` (the linked Project's Tenant) when given. */
97
+ export declare function runStudioCommand(args: readonly string[], deps: StackDeps, options?: {
98
+ next?: string;
99
+ }): Promise<number>;
100
+ export declare function isStackCommand(name: string | undefined): boolean;
101
+ /** Run one stack command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`). */
102
+ export declare function runStackCommand(name: string, args: readonly string[], deps: StackDeps): Promise<number>;
103
+ export {};
@@ -0,0 +1,478 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, rm, mkdir } from "node:fs/promises";
3
+ import { PROTOCOL_HEADER, PROTOCOL_VERSION } from "@nylorun/core/compatibility";
4
+ import { CliError } from "../errors.js";
5
+ import { resolveHome } from "../home.js";
6
+ import { dockerPreflight, parseComposePs, } from "./docker.js";
7
+ import { readAdminKey, readHostConfig, STACK_CLIENT_HOST } from "./host-files.js";
8
+ import { stackImages } from "./images.js";
9
+ import { stackPaths } from "./paths.js";
10
+ import { prepareStack, readStackEnv } from "./prepare.js";
11
+ import { mintStudioLogin, studioOrigin } from "./studio-login.js";
12
+ export const STACK_SERVICES = ["postgres", "restate", "s2", "runtime", "studio"];
13
+ const CORE_SERVICES = ["postgres", "restate", "s2", "runtime"];
14
+ const DEFAULT_PROJECT = "nylorun";
15
+ export const stackUsage = ` up|start [--no-studio] set up the stack on first run, then start it; print the Runtime URL and a Studio login URL
16
+ down|stop stop the stack's containers; keep volumes
17
+ status [--json] [--env] services, endpoints and Runtime health (--env: the linked Project's variables)
18
+ logs [service] [-f] [--tail <n>] stack logs (${STACK_SERVICES.join(", ")})
19
+ studio [--no-open] open a fresh Studio login (on the linked Project's Tenant); start the stack if it is stopped
20
+ reset [--yes] delete the stack's volumes and Tenant directories`;
21
+ const usageError = (message) => new CliError(message, 2);
22
+ export function stackProject(env) {
23
+ const project = env.NYLORUN_STACK_PROJECT?.trim() || DEFAULT_PROJECT;
24
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(project))
25
+ throw usageError(`NYLORUN_STACK_PROJECT must be lowercase letters, digits, "-" or "_": ${project}`);
26
+ return project;
27
+ }
28
+ function context(deps) {
29
+ return {
30
+ deps,
31
+ paths: stackPaths(resolveHome(undefined, deps.env)),
32
+ project: stackProject(deps.env),
33
+ };
34
+ }
35
+ function composeArgs(ctx, ...args) {
36
+ return [
37
+ "compose",
38
+ "--project-name",
39
+ ctx.project,
40
+ "--file",
41
+ ctx.paths.compose,
42
+ "--env-file",
43
+ ctx.paths.env,
44
+ ...args,
45
+ ];
46
+ }
47
+ function requireStackFiles(ctx) {
48
+ if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
49
+ throw new CliError(`No Nylorun stack under ${ctx.paths.root}. Run "nylorun start" first.`, 3);
50
+ }
51
+ export function parseStackFlags(args, allowed, usage) {
52
+ const booleans = new Set();
53
+ const values = new Map();
54
+ const rest = [];
55
+ for (let index = 0; index < args.length; index += 1) {
56
+ const raw = args[index];
57
+ const arg = allowed.aliases?.[raw] ?? raw;
58
+ if (!arg.startsWith("-")) {
59
+ rest.push(arg);
60
+ continue;
61
+ }
62
+ if (allowed.booleans?.includes(arg)) {
63
+ booleans.add(arg);
64
+ continue;
65
+ }
66
+ if (allowed.values?.includes(arg)) {
67
+ const value = args[++index];
68
+ if (value === undefined || value.startsWith("-"))
69
+ throw usageError(`${arg} requires a value.`);
70
+ values.set(arg, value);
71
+ continue;
72
+ }
73
+ throw usageError(`Unknown option ${raw}. Usage: ${usage}`);
74
+ }
75
+ return { rest, booleans, values };
76
+ }
77
+ async function sleep(ms) {
78
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
79
+ }
80
+ async function fetchHealth(deps, runtimeUrl) {
81
+ try {
82
+ const response = await deps.fetch(`${runtimeUrl}/health`, {
83
+ signal: AbortSignal.timeout(3000),
84
+ redirect: "error",
85
+ });
86
+ if (!response.ok)
87
+ return undefined;
88
+ return (await response.json());
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ async function waitForHealth(ctx, runtimeUrl, hostId) {
95
+ const deadline = Date.now() + (ctx.deps.healthTimeoutMs ?? 60_000);
96
+ for (;;) {
97
+ const health = await fetchHealth(ctx.deps, runtimeUrl);
98
+ if (health?.status === "ok") {
99
+ if (health.hostId !== hostId)
100
+ throw new CliError(`The Runtime at ${runtimeUrl} reports Host ${health.hostId ?? "(none)"}, not ${hostId} from ${ctx.paths.config}. Another Runtime holds the port; stop it or change NYLORUN_PORT in ${ctx.paths.env}.`, 4);
101
+ return health;
102
+ }
103
+ if (Date.now() > deadline)
104
+ throw new CliError(`The Runtime did not answer ${runtimeUrl}/health. See "nylorun logs runtime".`, 7);
105
+ await sleep(ctx.deps.pollMs ?? 500);
106
+ }
107
+ }
108
+ /**
109
+ * A Runtime started by the removed launcher (`nylorun runtime up`, the
110
+ * `nylorun-runtime` bin) shares host.json; refuse to fight it.
111
+ */
112
+ async function refuseLauncherRuntime(ctx) {
113
+ let state;
114
+ try {
115
+ state = JSON.parse(await readFile(ctx.paths.state, "utf8"));
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ if (typeof state?.pid === "number" && ctx.deps.pidAlive(state.pid))
121
+ throw new CliError(`A Runtime started by the old "nylorun runtime up" is running from ${ctx.paths.root} (pid ${state.pid}). Stop it with "nylorun-runtime --home ${ctx.paths.root} down" or end pid ${state.pid}, then run "nylorun start".`, 4);
122
+ }
123
+ async function composePs(ctx) {
124
+ const result = await ctx.deps.docker.run(composeArgs(ctx, "ps", "--all", "--format", "json"));
125
+ if (result.code !== 0)
126
+ return [];
127
+ try {
128
+ return parseComposePs(result.stdout);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ }
134
+ function isUp(services, name) {
135
+ const service = services.find((s) => s.service === name);
136
+ return service?.state === "running" && (service.health === "" || service.health === "healthy");
137
+ }
138
+ async function bringUp(ctx, options) {
139
+ const { deps } = ctx;
140
+ await refuseLauncherRuntime(ctx);
141
+ const prepared = await prepareStack({
142
+ paths: ctx.paths,
143
+ images: stackImages(deps.env, {
144
+ runtime: deps.runtimeVersion,
145
+ studio: deps.studioVersion,
146
+ }),
147
+ uid: deps.uid,
148
+ gid: deps.gid,
149
+ runtimeVersion: deps.runtimeVersion,
150
+ ports: deps.ports,
151
+ });
152
+ if (prepared.firstRun)
153
+ deps.err(`Wrote ${ctx.paths.compose} and ${ctx.paths.env} (Runtime port ${prepared.env.runtimePort}, Studio port ${prepared.env.studioPort}).`);
154
+ const up = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "300", ...CORE_SERVICES));
155
+ if (up !== 0)
156
+ throw new CliError(`docker compose up failed (exit ${up}). See "nylorun logs runtime" and "nylorun status".`, 7);
157
+ const runtimeUrl = `http://${STACK_CLIENT_HOST}:${prepared.env.runtimePort}`;
158
+ await waitForHealth(ctx, runtimeUrl, prepared.host.hostId);
159
+ let studioStarted = false;
160
+ if (options.studio) {
161
+ const studio = await deps.docker.stream(composeArgs(ctx, "up", "--detach", "--wait", "--wait-timeout", "120", "studio"));
162
+ studioStarted = studio === 0;
163
+ if (!studioStarted)
164
+ deps.err(`Warning: Studio did not start (image ${prepared.env.studioImage}). The Runtime is up; see "nylorun logs studio".`);
165
+ }
166
+ return {
167
+ runtimeUrl,
168
+ hostId: prepared.host.hostId,
169
+ studioPort: prepared.env.studioPort,
170
+ studioStarted,
171
+ adminKey: prepared.adminKey,
172
+ };
173
+ }
174
+ async function tryStudioLogin(ctx, studioPort, adminKey) {
175
+ const deadline = Date.now() + (ctx.deps.loginTimeoutMs ?? 15_000);
176
+ let lastError = "";
177
+ for (;;) {
178
+ try {
179
+ return await mintStudioLogin({ fetch: ctx.deps.fetch, studioPort, adminKey });
180
+ }
181
+ catch (error) {
182
+ lastError = error instanceof Error ? error.message : String(error);
183
+ }
184
+ if (Date.now() > deadline)
185
+ break;
186
+ await sleep(ctx.deps.pollMs ?? 500);
187
+ }
188
+ ctx.deps.err(`Warning: Studio at ${studioOrigin(studioPort)} is not reachable for a login (${lastError}).`);
189
+ return undefined;
190
+ }
191
+ /** Printed by `start` while the Host has no Tenant: nylorun never creates one. */
192
+ export const TENANT_HINT = "No Tenant yet. Create one in Studio, or run `npx @nylorun/cli tenant create` in your project.";
193
+ async function start(ctx, args) {
194
+ const flags = parseStackFlags(args, { booleans: ["--no-studio"] }, "nylorun start [--no-studio]");
195
+ if (flags.rest.length)
196
+ throw usageError("Usage: nylorun start [--no-studio]");
197
+ await dockerPreflight(ctx.deps.docker);
198
+ const started = await bringUp(ctx, { studio: !flags.booleans.has("--no-studio") });
199
+ ctx.deps.out(`Runtime ${started.runtimeUrl}`);
200
+ if (started.studioStarted) {
201
+ const login = await tryStudioLogin(ctx, started.studioPort, started.adminKey);
202
+ if (login)
203
+ ctx.deps.out(`Studio ${login}`);
204
+ }
205
+ if ((await adminTenantCount(ctx.deps, started.runtimeUrl, started.adminKey)) === 0)
206
+ ctx.deps.err(TENANT_HINT);
207
+ return 0;
208
+ }
209
+ async function stop(ctx, args) {
210
+ if (args.length)
211
+ throw usageError("Usage: nylorun stop");
212
+ requireStackFiles(ctx);
213
+ await dockerPreflight(ctx.deps.docker);
214
+ const code = await ctx.deps.docker.stream(composeArgs(ctx, "stop"));
215
+ if (code !== 0)
216
+ throw new CliError(`docker compose stop failed (exit ${code}).`, 1);
217
+ ctx.deps.out("Stopped the Nylorun stack; volumes are kept.");
218
+ return 0;
219
+ }
220
+ async function adminTenantCount(deps, runtimeUrl, adminKey) {
221
+ if (!adminKey)
222
+ return undefined;
223
+ try {
224
+ const response = await deps.fetch(`${runtimeUrl}/v1/admin/status`, {
225
+ headers: {
226
+ authorization: `Bearer ${adminKey}`,
227
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
228
+ accept: "application/json",
229
+ },
230
+ signal: AbortSignal.timeout(3000),
231
+ redirect: "error",
232
+ });
233
+ if (!response.ok)
234
+ return undefined;
235
+ const body = (await response.json());
236
+ return Array.isArray(body.tenants) ? body.tenants.length : undefined;
237
+ }
238
+ catch {
239
+ return undefined;
240
+ }
241
+ }
242
+ async function stackStatus(ctx) {
243
+ const base = {
244
+ project: ctx.project,
245
+ home: ctx.paths.root,
246
+ state: "absent",
247
+ runtime: { healthy: false },
248
+ studio: { state: "absent" },
249
+ restate: {},
250
+ services: [],
251
+ };
252
+ if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
253
+ return base;
254
+ await dockerPreflight(ctx.deps.docker);
255
+ const persisted = (await readStackEnv(ctx.paths)) ?? {};
256
+ const services = await composePs(ctx);
257
+ const runtimeUrl = persisted.runtimePort
258
+ ? `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}`
259
+ : undefined;
260
+ const health = runtimeUrl ? await fetchHealth(ctx.deps, runtimeUrl) : undefined;
261
+ const host = await readHostConfig(ctx.paths);
262
+ const healthy = health?.status === "ok" && (host === undefined || health.hostId === host.hostId);
263
+ const tenants = healthy && runtimeUrl
264
+ ? await adminTenantCount(ctx.deps, runtimeUrl, await readAdminKey(ctx.paths))
265
+ : undefined;
266
+ const studio = services.find((s) => s.service === "studio");
267
+ return {
268
+ ...base,
269
+ state: services.some((s) => s.state === "running") ? "running" : "stopped",
270
+ runtime: {
271
+ ...(runtimeUrl ? { url: runtimeUrl } : {}),
272
+ healthy,
273
+ ...(health?.version ? { version: health.version } : {}),
274
+ ...(health?.hostId ? { hostId: health.hostId } : {}),
275
+ ...(tenants !== undefined ? { tenants } : {}),
276
+ },
277
+ studio: {
278
+ ...(persisted.studioPort ? { url: studioOrigin(persisted.studioPort) } : {}),
279
+ state: studio ? [studio.state, studio.health].filter(Boolean).join(", ") : "absent",
280
+ },
281
+ restate: persisted.restatePort
282
+ ? { url: `http://${STACK_CLIENT_HOST}:${persisted.restatePort}` }
283
+ : {},
284
+ services,
285
+ };
286
+ }
287
+ /** What `nylorun status` reports, as data (`nylorun doctor`). */
288
+ export async function readStackStatus(deps) {
289
+ return await stackStatus(context(deps));
290
+ }
291
+ async function status(ctx, args) {
292
+ const flags = parseStackFlags(args, { booleans: ["--json"] }, "nylorun status [--json]");
293
+ if (flags.rest.length)
294
+ throw usageError("Usage: nylorun status [--json]");
295
+ const result = await stackStatus(ctx);
296
+ const { out } = ctx.deps;
297
+ if (flags.booleans.has("--json")) {
298
+ out(JSON.stringify(result, null, 2));
299
+ }
300
+ else if (result.state === "absent") {
301
+ out(`Stack absent (no stack under ${result.home}; run "nylorun start")`);
302
+ }
303
+ else {
304
+ out(`Stack ${result.state} (project ${result.project})`);
305
+ const runtimeDetail = result.runtime.healthy
306
+ ? `healthy, ${result.runtime.version ?? "?"}, ${result.runtime.hostId ?? "?"}${result.runtime.tenants !== undefined ? `, ${result.runtime.tenants} Tenant(s)` : ""}`
307
+ : "not answering";
308
+ out(`Runtime ${result.runtime.url ?? "?"} ${runtimeDetail}`);
309
+ out(`Studio ${result.studio.url ?? "?"} ${result.studio.state} (log in with "nylorun studio")`);
310
+ if (result.restate.url)
311
+ out(`Restate UI ${result.restate.url}`);
312
+ out(`Services ${STACK_SERVICES.map((name) => {
313
+ const service = result.services.find((s) => s.service === name);
314
+ const state = service
315
+ ? [service.state, service.health].filter(Boolean).join("/")
316
+ : "absent";
317
+ return `${name} ${state}`;
318
+ }).join(", ")}`);
319
+ out(`Host root ${result.home} (admin key in host-credentials.json, mode 0600)`);
320
+ }
321
+ return result.runtime.healthy ? 0 : 3;
322
+ }
323
+ async function logs(ctx, args) {
324
+ const usage = `nylorun logs [${STACK_SERVICES.join("|")}] [-f] [--tail <n>]`;
325
+ const flags = parseStackFlags(args, { booleans: ["--follow"], values: ["--tail"], aliases: { "-f": "--follow" } }, usage);
326
+ if (flags.rest.length > 1)
327
+ throw usageError(`Usage: ${usage}`);
328
+ const service = flags.rest[0];
329
+ if (service !== undefined && !STACK_SERVICES.includes(service))
330
+ throw usageError(`Unknown service ${service}. Usage: ${usage}`);
331
+ const tail = flags.values.get("--tail");
332
+ if (tail !== undefined && !/^\d+$/.test(tail))
333
+ throw usageError(`Invalid --tail: ${tail}`);
334
+ requireStackFiles(ctx);
335
+ await dockerPreflight(ctx.deps.docker);
336
+ return await ctx.deps.docker.stream(composeArgs(ctx, "logs", ...(flags.booleans.has("--follow") ? ["--follow"] : []), ...(tail !== undefined ? ["--tail", tail] : []), ...(service ? [service] : [])));
337
+ }
338
+ async function reset(ctx, args) {
339
+ const flags = parseStackFlags(args, { booleans: ["--yes"], aliases: { "-y": "--yes" } }, "nylorun reset [--yes]");
340
+ if (flags.rest.length)
341
+ throw usageError("Usage: nylorun reset [--yes]");
342
+ const { deps, paths } = ctx;
343
+ if (!flags.booleans.has("--yes")) {
344
+ const question = `Delete the stack's volumes (project ${ctx.project}) and every Tenant under ${paths.tenants}? This cannot be undone. [y/N] `;
345
+ if (!deps.confirm)
346
+ throw usageError("nylorun reset deletes all Tenants; pass --yes to confirm when not in a terminal.");
347
+ if (!(await deps.confirm(question))) {
348
+ deps.err("Reset cancelled.");
349
+ return 1;
350
+ }
351
+ }
352
+ if (existsSync(paths.compose) && existsSync(paths.env)) {
353
+ await dockerPreflight(deps.docker);
354
+ const code = await deps.docker.stream(composeArgs(ctx, "down", "--volumes", "--remove-orphans"));
355
+ if (code !== 0)
356
+ throw new CliError(`docker compose down failed (exit ${code}).`, 1);
357
+ }
358
+ await rm(paths.tenants, { recursive: true, force: true });
359
+ await mkdir(paths.tenants, { recursive: true, mode: 0o700 });
360
+ deps.out(`Reset the Nylorun stack: volumes and Tenant directories deleted. Run "nylorun start" to start again.`);
361
+ return 0;
362
+ }
363
+ /** The stack when the Runtime (and Studio, if wanted) already answer; otherwise undefined. */
364
+ async function runningStack(ctx, options) {
365
+ if (!existsSync(ctx.paths.compose) || !existsSync(ctx.paths.env))
366
+ return undefined;
367
+ const persisted = await readStackEnv(ctx.paths);
368
+ const adminKey = await readAdminKey(ctx.paths);
369
+ const host = await readHostConfig(ctx.paths);
370
+ if (!persisted?.runtimePort || !persisted.studioPort || !adminKey)
371
+ return undefined;
372
+ if (typeof host?.hostId !== "string")
373
+ return undefined;
374
+ const services = await composePs(ctx);
375
+ if (!isUp(services, "runtime"))
376
+ return undefined;
377
+ const studioUp = isUp(services, "studio");
378
+ if (options.studio && !studioUp)
379
+ return undefined;
380
+ const runtimeUrl = `http://${STACK_CLIENT_HOST}:${persisted.runtimePort}`;
381
+ const health = await fetchHealth(ctx.deps, runtimeUrl);
382
+ if (health?.status !== "ok" || health.hostId !== host.hostId)
383
+ return undefined;
384
+ return {
385
+ home: ctx.paths.root,
386
+ runtimeUrl,
387
+ hostId: host.hostId,
388
+ adminKey,
389
+ studioPort: persisted.studioPort,
390
+ studioUp,
391
+ started: false,
392
+ };
393
+ }
394
+ /**
395
+ * Start the stack unless it is already running (`nylorun studio`): the
396
+ * `start` code path without its own output. Compose progress
397
+ * still streams, since the first run pulls images.
398
+ */
399
+ export async function ensureStack(deps, options) {
400
+ const ctx = context(deps);
401
+ await dockerPreflight(deps.docker);
402
+ const running = await runningStack(ctx, options);
403
+ if (running)
404
+ return running;
405
+ const runtimeWasUp = isUp(existsSync(ctx.paths.compose) ? await composePs(ctx) : [], "runtime");
406
+ const started = await bringUp(ctx, options);
407
+ return {
408
+ home: ctx.paths.root,
409
+ runtimeUrl: started.runtimeUrl,
410
+ hostId: started.hostId,
411
+ adminKey: started.adminKey,
412
+ studioPort: started.studioPort,
413
+ studioUp: started.studioStarted,
414
+ started: !runtimeWasUp,
415
+ };
416
+ }
417
+ /** Add Studio's `next` path (e.g. `/tenants/<id>`) to a login URL. */
418
+ export function withNext(loginUrl, next) {
419
+ if (!next)
420
+ return loginUrl;
421
+ const url = new URL(loginUrl);
422
+ url.searchParams.set("next", next);
423
+ return url.toString();
424
+ }
425
+ /** The Studio page for one Tenant, as a login `next` path. */
426
+ export function tenantStudioPath(tenantId) {
427
+ return `/tenants/${encodeURIComponent(tenantId)}`;
428
+ }
429
+ /**
430
+ * Mint a fresh Studio login URL on a running stack, landing on `next`.
431
+ * Undefined (after a warning) when Studio does not answer.
432
+ */
433
+ export async function studioLoginUrl(deps, stack, next) {
434
+ const login = await tryStudioLogin(context(deps), stack.studioPort, stack.adminKey);
435
+ return login === undefined ? undefined : withNext(login, next);
436
+ }
437
+ async function studio(ctx, args, options = {}) {
438
+ const flags = parseStackFlags(args, { booleans: ["--no-open"] }, "nylorun studio [--no-open]");
439
+ if (flags.rest.length)
440
+ throw usageError("Usage: nylorun studio [--no-open]");
441
+ const stack = await ensureStack(ctx.deps, { studio: true });
442
+ if (stack.started)
443
+ ctx.deps.out(`Runtime ${stack.runtimeUrl}`);
444
+ if (!stack.studioUp)
445
+ throw new CliError(`Studio did not start. See "nylorun logs studio".`, 7);
446
+ const login = await studioLoginUrl(ctx.deps, stack, options.next);
447
+ if (!login)
448
+ return 1;
449
+ ctx.deps.out(`Studio ${login}`);
450
+ if (!flags.booleans.has("--no-open"))
451
+ await ctx.deps.openBrowser(login);
452
+ return 0;
453
+ }
454
+ /** `nylorun studio`, landing on `next` (the linked Project's Tenant) when given. */
455
+ export async function runStudioCommand(args, deps, options = {}) {
456
+ return await studio(context(deps), args, options);
457
+ }
458
+ const COMMANDS = {
459
+ start,
460
+ stop,
461
+ // Docker Compose spellings.
462
+ up: start,
463
+ down: stop,
464
+ status,
465
+ logs,
466
+ reset,
467
+ studio: (ctx, args) => studio(ctx, args),
468
+ };
469
+ export function isStackCommand(name) {
470
+ return name !== undefined && Object.hasOwn(COMMANDS, name);
471
+ }
472
+ /** Run one stack command (`up`/`start`, `down`/`stop`, `status`, `logs`, `reset`, `studio`). */
473
+ export async function runStackCommand(name, args, deps) {
474
+ const command = COMMANDS[name];
475
+ if (!command)
476
+ throw usageError(`Unknown stack command ${name}.\n${stackUsage}`);
477
+ return await command(context(deps), args);
478
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The local stack's Compose file (Runtime Architecture §14.3), written to
3
+ * `<Host root>/stack/compose.yaml` by `nylorun start`. It is the same for every
4
+ * machine: everything that varies (ports, the Postgres password, UID/GID, the
5
+ * Host root, the Runtime and Studio images) comes from `stack/.env`.
6
+ *
7
+ * Tenants are Postgres schemas, executed through Restate, with their history in
8
+ * s2-lite; the Runtime's /ready checks all three.
9
+ *
10
+ * Restate signs requests to the Worker endpoint with the private key in
11
+ * `stack/restate-identity.pem`, mounted read-only; the Runtime gets the public
12
+ * key as NYLORUN_RESTATE_IDENTITY_KEY.
13
+ */
14
+ export declare function renderComposeFile(): string;