@monster-orange/den 0.2.4 → 0.2.6

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 (40) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/docs/distributions.md +158 -16
  3. package/package.json +1 -1
  4. package/src/pillars/security/discover.ts +1 -1
  5. package/src/pillars/smoke/setup-global.ts +2 -1
  6. package/src/scripts/configure-aws-profiles.ts +2 -1
  7. package/src/scripts/register-repos.ts +4 -3
  8. package/src/shared/constants.ts +7 -0
  9. package/src/shared/den-config.ts +130 -12
  10. package/src/shared/engine.ts +18 -0
  11. package/src/shared/environment-manager/default-workspace.ts +229 -87
  12. package/src/shared/environment-manager/health.ts +235 -35
  13. package/src/shared/manifest/placement.ts +1 -1
  14. package/src/shared/manifest/schema.ts +47 -0
  15. package/src/shared/manifest/service-links.ts +1 -1
  16. package/src/shared/manifest/validate-manifest.ts +1 -1
  17. package/src/shared/plural.ts +13 -0
  18. package/src/shared/service-url.ts +2 -5
  19. package/src/shared/stacks/compose-health.ts +242 -0
  20. package/src/shared/stacks/runtime.ts +73 -24
  21. package/src/shared/stacks/status.ts +3 -2
  22. package/src/shared/types/index.ts +13 -1
  23. package/src/ui/cli/create.ts +2 -1
  24. package/src/ui/cli/health-display.ts +1 -0
  25. package/src/ui/cli/index.ts +86 -41
  26. package/src/ui/cli/mcp-connect.ts +36 -5
  27. package/src/ui/cli/server-identity.ts +233 -0
  28. package/src/ui/cli/service-command.ts +136 -12
  29. package/src/ui/cli/spinner.ts +48 -0
  30. package/src/ui/cli/stack-command.ts +40 -12
  31. package/src/ui/cli/web-command.ts +101 -32
  32. package/src/ui/mcp/api.ts +94 -1
  33. package/src/ui/mcp/server.ts +6 -1
  34. package/src/ui/mcp/tools.ts +6 -2
  35. package/src/ui/server/health-row.ts +6 -3
  36. package/src/ui/server/index.ts +120 -40
  37. package/src/ui/server/public/changelog-view.js +16 -0
  38. package/src/ui/server/public/header.js +121 -12
  39. package/src/ui/server/ui-assets.ts +1 -0
  40. package/src/ui/server/workspace-routes.ts +7 -0
@@ -25,6 +25,13 @@ import { product } from "../../shared/product.js";
25
25
  import { getDistRoot } from "../../shared/paths.js";
26
26
  import { readWebPort } from "../../shared/edge/config.js";
27
27
  import { DEFAULT_PORT } from "../../shared/constants.js";
28
+ import {
29
+ classifyServerInfo,
30
+ foreignServerMessage,
31
+ ownServer,
32
+ pickAnotherPortHint,
33
+ type OwnServer,
34
+ } from "./server-identity.js";
28
35
  import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
29
36
 
30
37
  export const MCP_CONNECT_SUBCOMMANDS = [
@@ -205,6 +212,8 @@ export const WEB_PROBE_TIMEOUT_MS = 1500;
205
212
  /** What a probe of the web server's /api/server-info found. */
206
213
  export type WebProbe =
207
214
  | { kind: "up"; version?: string }
215
+ /** Another distribution's server (see server-identity.ts). */
216
+ | { kind: "foreign"; who: string }
208
217
  /** Something accepted the connection but sent no answer in time. */
209
218
  | { kind: "timeout" }
210
219
  /** Nothing answered: connection refused, or not the web server. */
@@ -214,13 +223,15 @@ export async function probeWebServer(
214
223
  url: string,
215
224
  timeoutMs: number = WEB_PROBE_TIMEOUT_MS,
216
225
  fetchImpl: typeof fetch = fetch,
226
+ own: OwnServer = ownServer(),
217
227
  ): Promise<WebProbe> {
218
228
  try {
219
229
  const r = await fetchImpl(`${url}/api/server-info`, {
220
230
  signal: AbortSignal.timeout(timeoutMs),
221
231
  });
222
- const info = (await r.json()) as { version?: string };
223
- return { kind: "up", version: info.version };
232
+ const id = classifyServerInfo(await r.json(), own);
233
+ if (id.kind === "foreign") return { kind: "foreign", who: id.who };
234
+ return { kind: "up", version: id.kind === "ours" ? id.version : undefined };
224
235
  } catch (e) {
225
236
  return (e as Error).name === "TimeoutError"
226
237
  ? { kind: "timeout" }
@@ -252,6 +263,11 @@ export function webServerMessage(
252
263
  chalk.green(`web server up at ${url}`) +
253
264
  (probe.version ? chalk.gray(` (Den ${probe.version})`) : "")
254
265
  );
266
+ if (probe.kind === "foreign")
267
+ return chalk.yellow(
268
+ `${foreignServerMessage(Number(new URL(url).port), probe.who)}: ` +
269
+ `agents would read another distribution; ${pickAnotherPortHint(opts.cli)}`,
270
+ );
255
271
  const svc = `\`${opts.cli} service status\``;
256
272
  if (probe.kind === "timeout") {
257
273
  const secs = (opts.timeoutMs ?? WEB_PROBE_TIMEOUT_MS) / 1000;
@@ -271,7 +287,7 @@ export function webServerMessage(
271
287
  );
272
288
  }
273
289
 
274
- async function webServerLine(port?: number): Promise<string> {
290
+ function webUrl(port?: number): string {
275
291
  let p = port;
276
292
  if (p === undefined) {
277
293
  try {
@@ -280,8 +296,12 @@ async function webServerLine(port?: number): Promise<string> {
280
296
  p = DEFAULT_PORT;
281
297
  }
282
298
  }
283
- const url = `http://localhost:${p}`;
284
- return webServerMessage(await probeWebServer(url), url, {
299
+ return `http://localhost:${p}`;
300
+ }
301
+
302
+ async function webServerLine(port?: number, probe?: WebProbe): Promise<string> {
303
+ const url = webUrl(port);
304
+ return webServerMessage(probe ?? (await probeWebServer(url)), url, {
285
305
  cli: product().cli,
286
306
  asService: webRunsAsService(),
287
307
  });
@@ -382,6 +402,17 @@ export async function runMcpConnect(argv: string[]): Promise<number> {
382
402
  return 1;
383
403
  }
384
404
 
405
+ // Never point agents at another distribution's server.
406
+ if (!remove) {
407
+ const probe = await probeWebServer(webUrl(flags.port));
408
+ if (probe.kind === "foreign") {
409
+ console.error(
410
+ ` ${chalk.red("✗")} ${await webServerLine(flags.port, probe)}`,
411
+ );
412
+ return 1;
413
+ }
414
+ }
415
+
385
416
  let failed = 0;
386
417
  for (const c of targets) {
387
418
  if (!c.scopes.includes(scope)) {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Whether the web server answering on a port is THIS distribution's.
3
+ *
4
+ * Several distributions can run a Den web server on one machine (each with
5
+ * its own root), so something answering on the configured port is not
6
+ * proof it is ours: `<cli> web up` once printed "already running" for
7
+ * another distribution's server, and `web down` could have signalled it.
8
+ * /api/server-info names the distribution root it serves and the product
9
+ * (since 0.2.6); every command that acts on "our" server asks this module
10
+ * first and refuses to act on a foreign one.
11
+ *
12
+ * A server from before 0.2.6 has no `root`. It does report its pid, and it
13
+ * runs with its distribution root as its working directory (`web up` and
14
+ * the launchd service both start it there), so that decides instead.
15
+ */
16
+ import { spawnSync } from "child_process";
17
+ import * as fs from "fs";
18
+ import * as http from "http";
19
+ import * as path from "path";
20
+ import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
21
+ import { getDistRoot } from "../../shared/paths.js";
22
+ import { product } from "../../shared/product.js";
23
+
24
+ /** The distribution a command acts for. */
25
+ export interface OwnServer {
26
+ root: string;
27
+ product: string;
28
+ }
29
+
30
+ /** What answers on a port, from this distribution's point of view. */
31
+ export type ServerIdentity =
32
+ /** Nothing listens there. */
33
+ | { kind: "none" }
34
+ /** Something accepted the connection but sent no answer in time. */
35
+ | { kind: "unresponsive" }
36
+ /**
37
+ * `pid`: the server's own pid (server-info reports it). Identity is what
38
+ * makes it ours, so a stop signals this pid even when its command line
39
+ * names no entry point we recognise (an older Den installed elsewhere, a
40
+ * hand-started server).
41
+ */
42
+ | { kind: "ours"; version?: string; pid?: number }
43
+ /** Someone else's server. `who` reads "acme-ops at /srv/acme-ops". */
44
+ | { kind: "foreign"; who: string };
45
+
46
+ /** How long one probe of /api/server-info may take. */
47
+ export const SERVER_INFO_TIMEOUT_MS = 1_500;
48
+
49
+ /** This distribution: its root and product name. */
50
+ export function ownServer(): OwnServer {
51
+ return { root: getDistRoot(), product: product().name };
52
+ }
53
+
54
+ function canonical(p: string): string {
55
+ try {
56
+ return fs.realpathSync(p);
57
+ } catch {
58
+ return path.resolve(p);
59
+ }
60
+ }
61
+
62
+ export function samePath(a: string, b: string): boolean {
63
+ return canonical(a) === canonical(b);
64
+ }
65
+
66
+ /** A process's working directory (lsof), or null when it can't be read. */
67
+ export function processCwd(pid: number): string | null {
68
+ const r = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
69
+ encoding: "utf-8",
70
+ timeout: CLI_SPAWN_TIMEOUT_MS,
71
+ });
72
+ if (r.status !== 0 || typeof r.stdout !== "string") return null;
73
+ const line = r.stdout.split(/\r?\n/).find((l) => l.startsWith("n"));
74
+ return line ? line.slice(1) : null;
75
+ }
76
+
77
+ /**
78
+ * Classify a parsed /api/server-info body. `cwdOf` reads a pid's working
79
+ * directory, for servers from before `root` was reported.
80
+ */
81
+ export function classifyServerInfo(
82
+ body: unknown,
83
+ own: OwnServer,
84
+ cwdOf: (pid: number) => string | null = processCwd,
85
+ ): ServerIdentity {
86
+ if (body === null || typeof body !== "object" || Array.isArray(body))
87
+ return { kind: "foreign", who: "a non-Den server" };
88
+ const info = body as Record<string, unknown>;
89
+ const version = typeof info.version === "string" ? info.version : undefined;
90
+ const prod = typeof info.product === "string" ? info.product : undefined;
91
+ const pid =
92
+ typeof info.pid === "number" && Number.isInteger(info.pid) && info.pid > 0
93
+ ? info.pid
94
+ : undefined;
95
+ const ours = (): ServerIdentity => ({
96
+ kind: "ours",
97
+ ...(version !== undefined ? { version } : {}),
98
+ ...(pid !== undefined ? { pid } : {}),
99
+ });
100
+
101
+ if (typeof info.root === "string" && info.root) {
102
+ if (
103
+ samePath(info.root, own.root) &&
104
+ (prod === undefined || prod === own.product)
105
+ )
106
+ return ours();
107
+ return {
108
+ kind: "foreign",
109
+ who: `${prod ?? "another server"} at ${info.root}`,
110
+ };
111
+ }
112
+
113
+ if (pid !== undefined) {
114
+ const cwd = cwdOf(pid);
115
+ if (cwd && samePath(cwd, own.root)) return ours();
116
+ }
117
+ if (version === undefined && info.pid === undefined)
118
+ return { kind: "foreign", who: "a non-Den server" };
119
+ return {
120
+ kind: "foreign",
121
+ who: `another server${version ? ` (Den ${version})` : ""}`,
122
+ };
123
+ }
124
+
125
+ /** Classify a raw /api/server-info answer (status and body text). */
126
+ export function classifyServerInfoResponse(
127
+ statusCode: number,
128
+ text: string,
129
+ own: OwnServer,
130
+ cwdOf?: (pid: number) => string | null,
131
+ ): ServerIdentity {
132
+ // A server still starting answers 200 with an empty body: not up yet.
133
+ if (statusCode >= 200 && statusCode < 300 && text.trim() === "")
134
+ return { kind: "unresponsive" };
135
+ if (statusCode < 200 || statusCode >= 300)
136
+ return { kind: "foreign", who: "a non-Den server" };
137
+ let body: unknown;
138
+ try {
139
+ body = JSON.parse(text);
140
+ } catch {
141
+ return { kind: "foreign", who: "a non-Den server" };
142
+ }
143
+ return classifyServerInfo(body, own, cwdOf);
144
+ }
145
+
146
+ /**
147
+ * GET /api/server-info on `port` and say whose server answered. `host`: the
148
+ * name the caller itself connects to (the MCP server's `localhost`).
149
+ */
150
+ export function identifyServer(
151
+ port: number,
152
+ own: OwnServer = ownServer(),
153
+ timeoutMs: number = SERVER_INFO_TIMEOUT_MS,
154
+ host: string = "127.0.0.1",
155
+ ): Promise<ServerIdentity> {
156
+ return new Promise((resolve) => {
157
+ let settled = false;
158
+ const finish = (v: ServerIdentity): void => {
159
+ if (settled) return;
160
+ settled = true;
161
+ resolve(v);
162
+ };
163
+ const req = http.get(
164
+ {
165
+ host,
166
+ port,
167
+ path: "/api/server-info",
168
+ timeout: timeoutMs,
169
+ agent: false,
170
+ },
171
+ (res) => {
172
+ let text = "";
173
+ res.setEncoding("utf-8");
174
+ res.on("data", (chunk: string) => {
175
+ text += chunk;
176
+ if (text.length > 1024 * 1024) {
177
+ req.destroy();
178
+ finish({ kind: "foreign", who: "a non-Den server" });
179
+ }
180
+ });
181
+ res.on("end", () =>
182
+ finish(classifyServerInfoResponse(res.statusCode ?? 0, text, own)),
183
+ );
184
+ res.on("error", () => finish({ kind: "unresponsive" }));
185
+ },
186
+ );
187
+ req.on("timeout", () => {
188
+ req.destroy();
189
+ finish({ kind: "unresponsive" });
190
+ });
191
+ req.on("error", (e: NodeJS.ErrnoException) =>
192
+ finish(
193
+ e.code === "ECONNREFUSED" ? { kind: "none" } : { kind: "unresponsive" },
194
+ ),
195
+ );
196
+ });
197
+ }
198
+
199
+ /** "port 7001 is in use by acme-ops at /srv/acme-ops". */
200
+ export function foreignServerMessage(port: number, who: string): string {
201
+ return `port ${port} is in use by ${who}`;
202
+ }
203
+
204
+ /** How to get out of the way: another port, for once or for good. */
205
+ export function pickAnotherPortHint(cli: string = product().cli): string {
206
+ return (
207
+ `pick another port: \`${cli} web up --port <n>\` for once, or set ` +
208
+ `web.port in config/local/user.yml (yours) or ${product().configFile} (the team's)`
209
+ );
210
+ }
211
+
212
+ /** The error a command raises instead of acting on a foreign server. */
213
+ export class ForeignServerError extends Error {
214
+ constructor(
215
+ readonly port: number,
216
+ readonly who: string,
217
+ ) {
218
+ super(
219
+ `${foreignServerMessage(port, who)}\n Hint: ${pickAnotherPortHint()}`,
220
+ );
221
+ this.name = "ForeignServerError";
222
+ }
223
+ }
224
+
225
+ /** Throw ForeignServerError when `port` is served by another distribution. */
226
+ export async function refuseForeignServer(
227
+ port: number,
228
+ own: OwnServer = ownServer(),
229
+ ): Promise<ServerIdentity> {
230
+ const id = await identifyServer(port, own);
231
+ if (id.kind === "foreign") throw new ForeignServerError(port, id.who);
232
+ return id;
233
+ }
@@ -9,6 +9,11 @@
9
9
  * `install` (which also restarts a running service) says the service is
10
10
  * running only once the web server answers /api/server-info with JSON: a
11
11
  * fresh server takes a few seconds to start, and answers empty until then.
12
+ *
13
+ * `install --plan` is read-only: it prints the plist install would write
14
+ * (install.sh print), the label, the paths and whose server answers on the
15
+ * port, and writes and loads nothing; exit 1 when that server is foreign.
16
+ * `status` also names a foreign server on the port.
12
17
  */
13
18
  import { spawnSync } from "child_process";
14
19
  import * as os from "os";
@@ -20,6 +25,17 @@ import {
20
25
  } from "../../shared/den-config.js";
21
26
  import { readWebPort } from "../../shared/edge/config.js";
22
27
  import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
28
+ import { product } from "../../shared/product.js";
29
+ import {
30
+ classifyServerInfo,
31
+ ForeignServerError,
32
+ foreignServerMessage,
33
+ identifyServer,
34
+ ownServer,
35
+ pickAnotherPortHint,
36
+ type OwnServer,
37
+ type ServerIdentity,
38
+ } from "./server-identity.js";
23
39
 
24
40
  /** Runs a command with extra env vars; returns its exit code. */
25
41
  export type ServiceRunner = (
@@ -30,6 +46,14 @@ export type ServiceRunner = (
30
46
 
31
47
  const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
32
48
 
49
+ /** The flags each subcommand takes; any other is refused before acting. */
50
+ const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
51
+ install: ["--plan"],
52
+ status: [],
53
+ print: [],
54
+ uninstall: [],
55
+ };
56
+
33
57
  const defaultRunner: ServiceRunner = (cmd, args, env) =>
34
58
  spawnSync(cmd, args, { stdio: "inherit", env: { ...process.env, ...env } })
35
59
  .status ?? 1;
@@ -39,12 +63,15 @@ export const READY_TIMEOUT_MS = 30_000;
39
63
 
40
64
  /**
41
65
  * Poll `url` until it answers 2xx with a JSON object, or `timeoutMs` passes.
42
- * Each attempt is bounded too, so a hung connection cannot outlast it.
66
+ * Each attempt is bounded too, so a hung connection cannot outlast it. A
67
+ * server that is not `own`'s (another distribution's, on the same port) is
68
+ * never "running": that throws ForeignServerError at once.
43
69
  */
44
70
  export async function waitForServerInfo(
45
71
  url: string,
46
72
  timeoutMs: number = READY_TIMEOUT_MS,
47
73
  intervalMs = 500,
74
+ own: OwnServer = ownServer(),
48
75
  ): Promise<boolean> {
49
76
  const deadline = Date.now() + timeoutMs;
50
77
  for (;;) {
@@ -56,9 +83,15 @@ export async function waitForServerInfo(
56
83
  });
57
84
  if (res.ok) {
58
85
  const body: unknown = JSON.parse(await res.text());
59
- if (body !== null && typeof body === "object") return true;
86
+ if (body !== null && typeof body === "object") {
87
+ const id = classifyServerInfo(body, own);
88
+ if (id.kind === "foreign")
89
+ throw new ForeignServerError(Number(new URL(url).port), id.who);
90
+ return true;
91
+ }
60
92
  }
61
- } catch {
93
+ } catch (e) {
94
+ if (e instanceof ForeignServerError) throw e;
62
95
  /* not up yet: refused, empty or partial body */
63
96
  }
64
97
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -74,9 +107,32 @@ export interface ServiceDeps {
74
107
  platform?: NodeJS.Platform;
75
108
  /** The web UI port (default: config web.port). */
76
109
  webPort?: number;
77
- /** Readiness poller (default: waitForServerInfo). */
78
- waitReady?: (url: string, timeoutMs: number) => Promise<boolean>;
110
+ /**
111
+ * Readiness poller (default: waitForServerInfo). Throws
112
+ * ForeignServerError when another distribution's server answers.
113
+ */
114
+ waitReady?: (
115
+ url: string,
116
+ timeoutMs: number,
117
+ own: OwnServer,
118
+ ) => Promise<boolean>;
79
119
  log?: (line: string) => void;
120
+ /** Whose server answers on a port (default: identifyServer). */
121
+ identify?: (port: number, own: OwnServer) => Promise<ServerIdentity>;
122
+ }
123
+
124
+ /** One line on whose server answers on `port`. */
125
+ export function identityLine(port: number, id: ServerIdentity): string {
126
+ switch (id.kind) {
127
+ case "none":
128
+ return `port ${port}: free (nothing listening)`;
129
+ case "unresponsive":
130
+ return `port ${port}: something accepted the connection but did not answer`;
131
+ case "ours":
132
+ return `port ${port}: this distribution's server${id.version ? ` (Den ${id.version})` : ""}${id.pid ? `, pid ${id.pid}` : ""}`;
133
+ case "foreign":
134
+ return `${foreignServerMessage(port, id.who)}; not this distribution's server`;
135
+ }
80
136
  }
81
137
 
82
138
  /** Run `service <sub>`; returns the exit code. */
@@ -84,13 +140,30 @@ export async function cmdService(
84
140
  args: string[],
85
141
  deps: ServiceDeps = {},
86
142
  ): Promise<number> {
87
- const sub = args[0];
143
+ const sub = args[0] as (typeof SUBCOMMANDS)[number] | undefined;
88
144
  if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
89
145
  throw new Error(
90
146
  `${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
91
147
  );
92
- // print only writes the plist; the rest talk to launchd.
93
- if (sub !== "print" && (deps.platform ?? process.platform) !== "darwin")
148
+ // A typo'd --plan must not install for real.
149
+ const extra = args
150
+ .slice(1)
151
+ .filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
152
+ if (extra.length)
153
+ throw new Error(
154
+ `service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
155
+ (SUBCOMMAND_FLAGS[sub!].length
156
+ ? ` (takes ${SUBCOMMAND_FLAGS[sub!].join(", ")})`
157
+ : ""),
158
+ );
159
+ const plan = sub === "install" && args.includes("--plan");
160
+ // print (and install --plan) only write the plist to stdout; the rest
161
+ // talk to launchd.
162
+ if (
163
+ sub !== "print" &&
164
+ !plan &&
165
+ (deps.platform ?? process.platform) !== "darwin"
166
+ )
94
167
  throw new Error(
95
168
  "the web UI service is a launchd LaunchAgent: macOS only (print works anywhere)",
96
169
  );
@@ -105,19 +178,70 @@ export async function cmdService(
105
178
  "service",
106
179
  "install.sh",
107
180
  );
108
- const code = (deps.run ?? defaultRunner)("bash", [script, sub!], {
181
+ const env = {
109
182
  DEN_ROOT: distRoot,
110
183
  DEN_SERVICE_LABEL: label,
111
184
  DEN_SERVICE_LOGS: logs,
185
+ };
186
+ const run = deps.run ?? defaultRunner;
187
+ const log = deps.log ?? ((l: string) => console.log(l));
188
+ const port = deps.webPort ?? readWebPort(distRoot);
189
+ const own: OwnServer = { root: distRoot, product: product().name };
190
+ const identify =
191
+ deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
192
+
193
+ if (plan) {
194
+ const home = deps.home ?? os.homedir();
195
+ log(`service install --plan (read-only: nothing is written or loaded)`);
196
+ log(` label: ${label}`);
197
+ log(
198
+ ` plist: ${path.join(home, "Library", "LaunchAgents", `${label}.plist`)}`,
199
+ );
200
+ log(` logs: ${path.join(logs, "web.log")}`);
201
+ log(` root: ${distRoot}`);
202
+ log(` engine: ${path.dirname(path.dirname(script))}`);
203
+ log(` url: http://localhost:${port}`);
204
+ const id = await identify(port, own);
205
+ log(` server: ${identityLine(port, id)}`);
206
+ log(` plist it would write:`);
207
+ // install.sh print writes the plist to stdout only.
208
+ const code = run("bash", [script, "print"], env);
209
+ if (code !== 0) return code;
210
+ if (id.kind === "foreign") {
211
+ log(`✗ install would refuse this port: ${pickAnotherPortHint()}`);
212
+ return 1;
213
+ }
214
+ return 0;
215
+ }
216
+
217
+ const code = run("bash", [script, sub!], {
218
+ ...env,
112
219
  // The script then leaves saying "running" to us (after the poll below).
113
220
  ...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
114
221
  });
222
+ if (sub === "status") {
223
+ const id = await identify(port, own);
224
+ if (id.kind === "foreign") log(`✗ ${identityLine(port, id)}`);
225
+ return code;
226
+ }
115
227
  if (sub !== "install" || code !== 0) return code;
116
228
 
117
- const log = deps.log ?? ((l: string) => console.log(l));
118
- const port = deps.webPort ?? readWebPort(distRoot);
119
229
  const url = `http://localhost:${port}/api/server-info`;
120
- if (await (deps.waitReady ?? waitForServerInfo)(url, READY_TIMEOUT_MS)) {
230
+ const waitReady =
231
+ deps.waitReady ??
232
+ ((u: string, t: number, o: OwnServer) => waitForServerInfo(u, t, 500, o));
233
+ let ready: boolean;
234
+ try {
235
+ ready = await waitReady(url, READY_TIMEOUT_MS, own);
236
+ } catch (e) {
237
+ if (!(e instanceof ForeignServerError)) throw e;
238
+ log(
239
+ `✗ ${label} installed, but ${foreignServerMessage(e.port, e.who)}, ` +
240
+ `so it cannot serve there: ${pickAnotherPortHint()}`,
241
+ );
242
+ return 1;
243
+ }
244
+ if (ready) {
121
245
  log(
122
246
  `✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
123
247
  );
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The CLI's progress spinners (ora).
3
+ *
4
+ * Spinners are progress, not diagnostics: on a terminal they animate on
5
+ * stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
6
+ * carries only real warnings and errors.
7
+ *
8
+ * They animate only on a terminal with a known, non-zero width. ora
9
+ * divides each line's width by the column count to know how many lines to
10
+ * clear before the next frame: on a pty that reports 0 columns (one nobody
11
+ * sized, as agent shells and some CI runners give) that is Infinity and the
12
+ * spinner clears lines forever. Without a usable terminal a spinner prints
13
+ * plain lines instead: its text once on start, and the final line.
14
+ */
15
+ import oraSpinner, { type Ora } from "ora";
16
+
17
+ /** A stream as a spinner sees it: a TTY, maybe, with a width, maybe. */
18
+ export interface SpinnerStream extends NodeJS.WritableStream {
19
+ isTTY?: boolean;
20
+ columns?: number;
21
+ }
22
+
23
+ /** Whether a spinner can draw frames on `stream`. */
24
+ export function canAnimate(stream: SpinnerStream): boolean {
25
+ return (
26
+ stream.isTTY === true &&
27
+ typeof stream.columns === "number" &&
28
+ Number.isFinite(stream.columns) &&
29
+ stream.columns > 0
30
+ );
31
+ }
32
+
33
+ function defaultStream(): SpinnerStream {
34
+ return process.stderr.isTTY ? process.stderr : process.stdout;
35
+ }
36
+
37
+ /** A spinner showing `text`, on stderr (a terminal) or stdout (piped). */
38
+ export function spinner(
39
+ text: string,
40
+ stream: SpinnerStream = defaultStream(),
41
+ ): Ora {
42
+ return oraSpinner({
43
+ text,
44
+ stream: stream as NodeJS.WritableStream,
45
+ // Unset, ora decides (never under CI or TERM=dumb); false: plain lines.
46
+ ...(canAnimate(stream) ? {} : { isEnabled: false }),
47
+ });
48
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "../../shared/stacks/runtime.js";
23
23
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
24
24
  import type { StackConfig } from "../../shared/stacks/types.js";
25
+ import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
25
26
 
26
27
  function serviceFlag(args: string[]): { service?: string; rest: string[] } {
27
28
  const rest: string[] = [];
@@ -56,9 +57,48 @@ export async function resolveStacksSecretEnv(
56
57
  return out;
57
58
  }
58
59
 
60
+ /**
61
+ * `stack health <name…>`: exit 0 when every stack is healthy, 1 when one is
62
+ * not, and STACK_HEALTH_UNKNOWN_EXIT when none failed but one could not be
63
+ * told (docker did not answer, the config could not be read). The web
64
+ * server's health row reads that as unknown, never as down.
65
+ */
66
+ function stackHealthCommand(args: string[], service?: string): void {
67
+ let failed = false;
68
+ let unknown = false;
69
+ try {
70
+ const ctx = loadStackContext();
71
+ const names = args.filter((a) => !a.startsWith("-"));
72
+ if (!names.length)
73
+ throw new Error(
74
+ `Usage: stack health <name…> (known: ${Object.keys(ctx.stacks).join(", ")})`,
75
+ );
76
+ for (const n of names) {
77
+ const s = ctx.stacks[n];
78
+ const h = s
79
+ ? stackHealth(ctx, s, service)
80
+ : { ok: false, detail: `unknown stack "${n}"` };
81
+ if (h.ok === false) failed = true;
82
+ if (h.ok === null) unknown = true;
83
+ const mark =
84
+ h.ok === true
85
+ ? chalk.green("✓")
86
+ : h.ok === false
87
+ ? chalk.red("✗")
88
+ : chalk.gray("?");
89
+ console.log(`${mark} ${n} ${h.detail}`);
90
+ }
91
+ } catch (e) {
92
+ console.error(chalk.gray(`? ${(e as Error).message}`));
93
+ unknown = true;
94
+ }
95
+ process.exitCode = failed ? 1 : unknown ? STACK_HEALTH_UNKNOWN_EXIT : 0;
96
+ }
97
+
59
98
  export async function cmdStack(argv: string[]): Promise<void> {
60
99
  const { service, rest } = serviceFlag(argv);
61
100
  const [sub = "ls", ...args] = rest;
101
+ if (sub === "health") return stackHealthCommand(args, service);
62
102
  // The global -w/--workspace is parsed (and checked) before we run and
63
103
  // arrives as DEN_WORKSPACE_OVERRIDE; loadStackContext reads it.
64
104
  const ctx = loadStackContext();
@@ -129,18 +169,6 @@ export async function cmdStack(argv: string[]): Promise<void> {
129
169
  }
130
170
  return;
131
171
  }
132
- case "health": {
133
- let ok = true;
134
- for (const n of pick()) {
135
- const h = stackHealth(ctx, stacks[n], service);
136
- ok &&= h.ok;
137
- console.log(
138
- `${h.ok ? chalk.green("✓") : chalk.red("✗")} ${n} ${h.detail}`,
139
- );
140
- }
141
- process.exitCode = ok ? 0 : 1;
142
- return;
143
- }
144
172
  case "down":
145
173
  for (const n of startOrder(pick(), stacks)
146
174
  .reverse()