@monster-orange/den 0.2.5 → 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.
@@ -58,13 +58,19 @@ export function composeHealthTarget(
58
58
  /** Budget for the shared listing; past it the checks are pending. */
59
59
  export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
60
60
 
61
- type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
61
+ /** The shared listing: every container, or why docker could not answer. */
62
+ export type ComposeListing =
63
+ | { rows: ComposeRow[] }
64
+ | { error: string; pending?: boolean };
65
+ type Listing = ComposeListing;
62
66
 
63
67
  let inFlight: Promise<Listing> | null = null;
64
68
 
65
69
  /**
66
- * Every compose container, from one `docker ps -a`. Concurrent callers share
67
- * the call in flight, so a health cycle lists once however many checks it has.
70
+ * Every container, from one `docker ps -a`. Concurrent callers share the
71
+ * call in flight, so a health cycle lists once however many checks it has
72
+ * (compose checks and `type: container` checks alike). Not filtered to
73
+ * compose containers: a container check may name a plain container.
68
74
  */
69
75
  export function composeListing(): Promise<Listing> {
70
76
  return (inFlight ??= listOnce().finally(() => {
@@ -96,10 +102,8 @@ function listOnce(): Promise<Listing> {
96
102
  [
97
103
  "ps",
98
104
  "-a",
99
- "--filter",
100
- "label=com.docker.compose.project",
101
105
  "--format",
102
- '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
106
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}\t{{.Names}}',
103
107
  ],
104
108
  {
105
109
  encoding: "utf-8",
@@ -125,9 +129,14 @@ function listOnce(): Promise<Listing> {
125
129
  .split("\n")
126
130
  .filter(Boolean)
127
131
  .map((line) => {
128
- const [project = "", service = "", state = "", status = ""] =
129
- line.split("\t");
130
- return { project, service, state, status };
132
+ const [
133
+ project = "",
134
+ service = "",
135
+ state = "",
136
+ status = "",
137
+ name = "",
138
+ ] = line.split("\t");
139
+ return { project, service, state, status, name };
131
140
  });
132
141
  resolve({ rows });
133
142
  },
@@ -135,16 +144,99 @@ function listOnce(): Promise<Listing> {
135
144
  });
136
145
  }
137
146
 
147
+ /**
148
+ * The verdict when docker gave no listing, shared by compose and container
149
+ * checks so the two cannot drift: container state unknown is not "not
150
+ * running" (`ok: null`, never down, however often it repeats). Only a
151
+ * listing past its budget is `pending` (no answer in time), which, like any
152
+ * timeout, is down when it repeats on the next cycle.
153
+ */
154
+ export function unknownListingVerdict(listing: {
155
+ error: string;
156
+ pending?: boolean;
157
+ }): ComposeVerdict {
158
+ return {
159
+ ok: null,
160
+ detail: `container state unknown: ${listing.error}`,
161
+ ...(listing.pending ? { pending: true } : {}),
162
+ };
163
+ }
164
+
138
165
  /** The health of one Den-generated compose check, from the shared listing. */
139
166
  export async function composeHealth(
140
167
  target: ComposeHealthTarget,
141
168
  ): Promise<ComposeVerdict> {
142
169
  const listing = await composeListing();
143
- if ("error" in listing)
170
+ if ("error" in listing) return unknownListingVerdict(listing);
171
+ return judgeStackRows(listing.rows, target.project, target.service);
172
+ }
173
+
174
+ // ── `health: { type: container }` ───────────────────────────────────────────
175
+
176
+ /**
177
+ * A declared container check: a compose container by its project and service
178
+ * labels, or any container by name (`container_name`).
179
+ */
180
+ export type ContainerHealthTarget =
181
+ | { project: string; service: string; container_name?: undefined }
182
+ | { container_name: string; project?: undefined; service?: undefined };
183
+
184
+ /** What a container check shows as its check: `container <project>/<service>`. */
185
+ export function containerCheckLabel(t: ContainerHealthTarget): string {
186
+ return `container ${containerTargetName(t)}`;
187
+ }
188
+
189
+ function containerTargetName(t: ContainerHealthTarget): string {
190
+ return t.container_name ?? `${t.project}/${t.service}`;
191
+ }
192
+
193
+ function matchesTarget(r: ComposeRow, t: ContainerHealthTarget): boolean {
194
+ if (t.container_name)
195
+ return (r.name ?? "")
196
+ .split(",")
197
+ .map((n) => n.trim().replace(/^\//, ""))
198
+ .includes(t.container_name);
199
+ return r.project === t.project && r.service === t.service;
200
+ }
201
+
202
+ /** One container's verdict: its healthcheck when it has one, else running. */
203
+ function rowVerdict(r: ComposeRow): ComposeVerdict & { rank: number } {
204
+ const what = `${r.name || `${r.project}/${r.service}`}\t${r.state}\t${r.status}`;
205
+ if (r.state !== "running") return { ok: false, detail: what, rank: 4 };
206
+ if (/\(unhealthy\)/.test(r.status))
207
+ return { ok: false, detail: what, rank: 3 };
208
+ // Starting is pending, but not "no answer": it never turns down on repeat.
209
+ if (/\(health: starting\)/.test(r.status))
210
+ return { ok: null, detail: what, rank: 2 };
211
+ if (/\(healthy\)/.test(r.status)) return { ok: true, detail: what, rank: 0 };
212
+ return { ok: true, detail: what, rank: 1 };
213
+ }
214
+
215
+ /**
216
+ * The verdict of a container check over the shared listing. More than one
217
+ * match: the best one (healthy, then running). No match: down. No listing:
218
+ * as a compose check (unknownListingVerdict), the reason in `detail`.
219
+ */
220
+ export function judgeContainerListing(
221
+ listing: ComposeListing,
222
+ target: ContainerHealthTarget,
223
+ ): ComposeVerdict {
224
+ if ("error" in listing) return unknownListingVerdict(listing);
225
+ const best = listing.rows
226
+ .filter((r) => matchesTarget(r, target))
227
+ .map(rowVerdict)
228
+ .sort((a, b) => a.rank - b.rank)[0];
229
+ if (!best)
144
230
  return {
145
- ok: null,
146
- detail: `container state unknown: ${listing.error}`,
147
- ...(listing.pending ? { pending: true } : {}),
231
+ ok: false,
232
+ detail: `${containerTargetName(target)}: no container`,
148
233
  };
149
- return judgeStackRows(listing.rows, target.project, target.service);
234
+ return { ok: best.ok, detail: best.detail };
235
+ }
236
+
237
+ /** The health of a declared container check, from the shared listing. */
238
+ export async function containerHealth(
239
+ target: ContainerHealthTarget,
240
+ ): Promise<ComposeVerdict> {
241
+ return judgeContainerListing(await composeListing(), target);
150
242
  }
@@ -413,6 +413,8 @@ export interface ComposeRow {
413
413
  service: string;
414
414
  state: string;
415
415
  status: string;
416
+ /** The container's name(s), comma-separated as docker lists them. */
417
+ name?: string;
416
418
  }
417
419
 
418
420
  /**
@@ -210,10 +210,22 @@ export type LogSource =
210
210
  * type: shell
211
211
  * cmd: "docker exec $(docker ps --filter ancestor=postgres:16 --format '{{.ID}}' | head -1) pg_isready -U postgres"
212
212
  * label: "postgres" # optional — shown in UI instead of cmd
213
+ *
214
+ * Container (answered from the one `docker ps` a health cycle shares):
215
+ * type: container
216
+ * project: shop-main # com.docker.compose.project label
217
+ * service: db # com.docker.compose.service label
218
+ * -- or, for a container not run by compose --
219
+ * type: container
220
+ * container_name: shop-db
213
221
  */
214
222
  export type ServiceHealthConfig =
215
223
  | { type?: "http"; url: string; label?: string }
216
- | { type: "shell"; cmd: string; label?: string };
224
+ | { type: "shell"; cmd: string; label?: string }
225
+ | ({ type: "container"; label?: string } & (
226
+ | { project: string; service: string; container_name?: undefined }
227
+ | { container_name: string; project?: undefined; service?: undefined }
228
+ ));
217
229
 
218
230
  /**
219
231
  * A command can be a plain shell string (detached by default) or an object
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
17
17
  import { profileFrom } from "../../shared/profile.js";
18
+ import { plural } from "../../shared/plural.js";
18
19
 
19
20
  export interface CreateOptions {
20
21
  /** Product name: lower-case `[a-z][a-z0-9-]*`. */
@@ -345,7 +346,7 @@ export function runCreate(argv: string[], cwd: string): number {
345
346
  const cli = args.cli ?? args.name;
346
347
  const rel = path.relative(cwd, args.dir) || ".";
347
348
  console.log(
348
- `Created ${args.name} in ${args.dir} (${written.length} files).`,
349
+ `Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
349
350
  );
350
351
  console.log("");
351
352
  console.log("Next:");
@@ -12,18 +12,10 @@ import {
12
12
  } from "../../shared/stacks/status.js";
13
13
  import { isNamedTarget } from "./named-target.js";
14
14
  import chalk from "chalk";
15
- import oraSpinner from "ora";
16
-
17
- /**
18
- * Spinners are progress, not diagnostics: on a terminal they animate on
19
- * stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
20
- * carries only real warnings and errors.
21
- */
22
- const ora = (text: string) =>
23
- oraSpinner({
24
- text,
25
- stream: process.stderr.isTTY ? process.stderr : process.stdout,
26
- });
15
+ import { noun, plural } from "../../shared/plural.js";
16
+ // Spinners: progress on stderr (a terminal) or stdout (piped); plain lines
17
+ // where they cannot animate. See spinner.ts.
18
+ import { spinner as ora } from "./spinner.js";
27
19
  import Table from "cli-table3";
28
20
  import { cliPrompts } from "./prompts.js";
29
21
  import { resultsViewer } from "./results-viewer.js";
@@ -87,6 +79,7 @@ import {
87
79
  import {
88
80
  blueprintWorkspaces,
89
81
  defaultWorkspaceNeedsSeeding,
82
+ blueprintAlreadySeeded,
90
83
  defaultWorkspaceHasServices,
91
84
  isMultiBlueprint,
92
85
  seedBlueprintWorkspaces,
@@ -496,8 +489,6 @@ function printValidation(
496
489
  layer?: string,
497
490
  ): void {
498
491
  const tag = layer ? `${layer}, ` : "";
499
- const plural = (n: number, word: string): string =>
500
- `${n} ${word}${n === 1 ? "" : "s"}`;
501
492
  if (result.valid && result.warnings.length === 0) {
502
493
  console.log(
503
494
  chalk.green(` ✓ ${label}`) +
@@ -2284,7 +2275,7 @@ class DenCLI {
2284
2275
  }
2285
2276
  console.log(
2286
2277
  chalk.cyan(
2287
- `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} systems`,
2278
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2288
2279
  ),
2289
2280
  );
2290
2281
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2345,7 +2336,7 @@ class DenCLI {
2345
2336
  const order = [...target.rawOrder].reverse();
2346
2337
  console.log(
2347
2338
  chalk.cyan(
2348
- `\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} systems`,
2339
+ `\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2349
2340
  ),
2350
2341
  );
2351
2342
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2516,13 +2507,26 @@ class DenCLI {
2516
2507
  return false;
2517
2508
  }
2518
2509
  const multi = isMultiBlueprint();
2510
+ // A repeat seed: say so, and touch nothing.
2511
+ if (blueprintAlreadySeeded()) {
2512
+ console.log(
2513
+ chalk.green(
2514
+ multi
2515
+ ? `\n ✓ ${plural(bps.length, "workspace")} already seeded (${bps.map((b) => b.name).join(", ")}): nothing changed.\n`
2516
+ : `\n ✓ Default workspace already seeded — ${plural(bps[0].repos.length, "repo")} (managed): nothing changed.\n`,
2517
+ ),
2518
+ );
2519
+ return false;
2520
+ }
2519
2521
  const willReset = defaultWorkspaceHasServices();
2520
2522
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2521
2523
 
2522
2524
  for (const bp of bps) {
2523
2525
  const label = multi ? `Workspace ${bp.name}` : "Default workspace";
2524
2526
  console.log(
2525
- chalk.cyan(`\n ${label} — ${bp.repos.length} repos, all managed:`),
2527
+ chalk.cyan(
2528
+ `\n ${label} — ${plural(bp.repos.length, "repo")}, all managed:`,
2529
+ ),
2526
2530
  );
2527
2531
  console.log(chalk.gray(` ${bp.repos.join(", ")}`));
2528
2532
  }
@@ -2563,7 +2567,7 @@ class DenCLI {
2563
2567
  const focus = readWorkspacesFile().focus;
2564
2568
  console.log(
2565
2569
  chalk.green(
2566
- ` ✓ ${seeded.length} workspaces ready (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
2570
+ ` ✓ ${plural(seeded.length, "workspace")} ${willReset ? "reset to the blueprint" : "ready"} (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
2567
2571
  `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2568
2572
  ),
2569
2573
  );
@@ -2572,7 +2576,7 @@ class DenCLI {
2572
2576
  const seeded = seedDefaultWorkspace();
2573
2577
  console.log(
2574
2578
  chalk.green(
2575
- ` ✓ Default workspace ready — ${seeded.length} repos (managed). ` +
2579
+ ` ✓ Default workspace ${willReset ? "reset to the blueprint" : "ready"} — ${plural(seeded.length, "repo")} (managed). ` +
2576
2580
  `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2577
2581
  ),
2578
2582
  );
@@ -2602,7 +2606,7 @@ class DenCLI {
2602
2606
  if (added.length > 0) {
2603
2607
  console.log(
2604
2608
  chalk.gray(
2605
- ` default workspace: added standard repo(s) ${added.join(", ")} (managed)`,
2609
+ ` default workspace: added standard ${noun(added.length, "repo")} ${added.join(", ")} (managed)`,
2606
2610
  ),
2607
2611
  );
2608
2612
  }
@@ -3802,7 +3806,7 @@ class DenCLI {
3802
3806
  if (missing.length > 0) {
3803
3807
  console.log(
3804
3808
  chalk.cyan(
3805
- `\n Cloning ${missing.length} managed repo(s) not on disk yet: ${missing.join(", ")}`,
3809
+ `\n Cloning ${plural(missing.length, "managed repo")} not on disk yet: ${missing.join(", ")}`,
3806
3810
  ),
3807
3811
  );
3808
3812
  await this.ensureManagedClonesFor(missing);
@@ -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
+ }