@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.
- package/CHANGELOG.md +98 -0
- package/docs/distributions.md +110 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/environment-manager/default-workspace.ts +47 -0
- package/src/shared/environment-manager/health.ts +45 -9
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +106 -14
- package/src/shared/stacks/runtime.ts +2 -0
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/index.ts +25 -21
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/web-command.ts +76 -25
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +32 -14
|
@@ -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
|
-
|
|
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
|
|
67
|
-
*
|
|
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 [
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
146
|
-
detail:
|
|
147
|
-
...(listing.pending ? { pending: true } : {}),
|
|
231
|
+
ok: false,
|
|
232
|
+
detail: `${containerTargetName(target)}: no container`,
|
|
148
233
|
};
|
|
149
|
-
return
|
|
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
|
}
|
|
@@ -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
|
package/src/ui/cli/create.ts
CHANGED
|
@@ -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
|
|
349
|
+
`Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
|
|
349
350
|
);
|
|
350
351
|
console.log("");
|
|
351
352
|
console.log("Next:");
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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))}
|
|
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))}
|
|
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(
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
223
|
-
return { kind: "
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
+
}
|