@monster-orange/den 0.2.5 → 0.2.7
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 +215 -0
- package/bin/den +9 -0
- package/docs/distributions.md +202 -22
- package/package.json +1 -1
- package/service/install.sh +41 -2
- 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/den-config.ts +38 -1
- package/src/shared/environment-manager/catalog.ts +9 -2
- package/src/shared/environment-manager/default-workspace.ts +92 -14
- package/src/shared/environment-manager/health.ts +96 -17
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +2 -1
- package/src/shared/environment-manager/workspace-org.ts +264 -0
- 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 +20 -1
- package/src/ui/cli/clone-plan.ts +243 -0
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/index.ts +274 -61
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/org-command.ts +162 -0
- package/src/ui/cli/results-viewer.ts +5 -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/table.ts +20 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/cli/web-command.ts +76 -25
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +30 -2
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +63 -18
- package/src/ui/server/release-service.ts +16 -3
- package/src/ui/server/workspace-routes.ts +4 -1
|
@@ -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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> org list` (ORG-1a): every org with its label, its workspaces on
|
|
3
|
+
* this machine, whether it is the default (`github_org`), and the org
|
|
4
|
+
* warnings (C1). The orgs are `/api/orgs`'s (catalog owners plus `orgs:`),
|
|
5
|
+
* then the default org and any workspace's org that list does not name.
|
|
6
|
+
*
|
|
7
|
+
* `--org <id>` (on `org list` and `workspace list`) keeps one org's rows.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { product } from "../../shared/product.js";
|
|
11
|
+
import { listOrgs } from "../../shared/manifest/service-links.js";
|
|
12
|
+
import {
|
|
13
|
+
defaultOrg,
|
|
14
|
+
githubOrgFor,
|
|
15
|
+
readOrgContext,
|
|
16
|
+
resolveAllWorkspaceOrgs,
|
|
17
|
+
withOrgMembership,
|
|
18
|
+
type OrgContext,
|
|
19
|
+
type WorkspaceOrg,
|
|
20
|
+
} from "../../shared/environment-manager/workspace-org.js";
|
|
21
|
+
import { newTable } from "./table.js";
|
|
22
|
+
|
|
23
|
+
/** `--org <id>`'s value from `args` (also `--org=<id>`), or undefined. */
|
|
24
|
+
export function orgFlag(args: readonly string[]): string | undefined {
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
const a = args[i];
|
|
27
|
+
if (a === "--org") return args[i + 1];
|
|
28
|
+
if (a.startsWith("--org=")) return a.slice("--org=".length);
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `args` without `--org <id>` / `--org=<id>`. */
|
|
34
|
+
export function withoutOrgFlag(args: readonly string[]): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
if (args[i] === "--org") {
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (args[i].startsWith("--org=")) continue;
|
|
42
|
+
out.push(args[i]);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const sameOrg = (a: string | null, b: string): boolean =>
|
|
48
|
+
a !== null && a.toLowerCase() === b.toLowerCase();
|
|
49
|
+
|
|
50
|
+
export interface OrgListRow {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
default: boolean;
|
|
54
|
+
workspaces: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The rows `org list` prints, and every workspace's warnings. */
|
|
58
|
+
export function orgListRows(ctx: OrgContext = readOrgContext()): {
|
|
59
|
+
rows: OrgListRow[];
|
|
60
|
+
warnings: string[];
|
|
61
|
+
resolved: WorkspaceOrg[];
|
|
62
|
+
} {
|
|
63
|
+
const resolved = resolveAllWorkspaceOrgs(ctx);
|
|
64
|
+
const listed = withOrgMembership(
|
|
65
|
+
listOrgs(ctx.catalog, ctx.config.orgs ?? {}),
|
|
66
|
+
resolved,
|
|
67
|
+
ctx.file,
|
|
68
|
+
ctx.config,
|
|
69
|
+
).map((o) => ({
|
|
70
|
+
id: o.id,
|
|
71
|
+
label: o.label,
|
|
72
|
+
default: o.default,
|
|
73
|
+
workspaces: o.workspaces,
|
|
74
|
+
}));
|
|
75
|
+
const local = new Set(Object.keys(ctx.file.workspaces));
|
|
76
|
+
const extra = new Map<string, OrgListRow>();
|
|
77
|
+
const add = (id: string, isDefault: boolean): void => {
|
|
78
|
+
if (listed.some((r) => sameOrg(r.id, id))) return;
|
|
79
|
+
const key = id.toLowerCase();
|
|
80
|
+
if (!extra.has(key))
|
|
81
|
+
extra.set(key, { id, label: id, default: isDefault, workspaces: [] });
|
|
82
|
+
};
|
|
83
|
+
const d = defaultOrg(ctx.config);
|
|
84
|
+
if (d) add(d, true);
|
|
85
|
+
for (const w of resolved)
|
|
86
|
+
if (w.org && local.has(w.workspace)) {
|
|
87
|
+
add(w.org, sameOrg(d, w.org));
|
|
88
|
+
extra.get(w.org.toLowerCase())?.workspaces.push(w.workspace);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
rows: [...listed, ...extra.values()],
|
|
92
|
+
warnings: resolved
|
|
93
|
+
.filter((w) => local.has(w.workspace))
|
|
94
|
+
.flatMap((w) => w.warnings),
|
|
95
|
+
resolved,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The GitHub org `register` lists repos from: `--org <id>`, else the
|
|
101
|
+
* workspace's resolved org. Never Den's own checkout or remote (SEC-0).
|
|
102
|
+
* Undefined when there is none, or it is not a GitHub owner name.
|
|
103
|
+
*/
|
|
104
|
+
export function registerOrg(
|
|
105
|
+
workspace: string,
|
|
106
|
+
orgArg?: string,
|
|
107
|
+
ctx?: OrgContext,
|
|
108
|
+
): string | undefined {
|
|
109
|
+
let org = orgArg?.trim();
|
|
110
|
+
if (!org) {
|
|
111
|
+
try {
|
|
112
|
+
org = githubOrgFor(workspace, ctx);
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org) ? org : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Print the org warnings under a listing. */
|
|
121
|
+
export function printOrgWarnings(warnings: readonly string[]): void {
|
|
122
|
+
for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
|
|
123
|
+
if (warnings.length) console.log();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function cmdOrg(args: string[]): void {
|
|
127
|
+
const sub = args[0] ?? "list";
|
|
128
|
+
if (sub !== "list") {
|
|
129
|
+
console.log(chalk.red(`Usage: ${product().cli} org list [--org <id>]`));
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const only = orgFlag(args.slice(1));
|
|
134
|
+
const { rows, warnings, resolved } = orgListRows();
|
|
135
|
+
const shown = only ? rows.filter((r) => sameOrg(r.id, only)) : rows;
|
|
136
|
+
const table = newTable({
|
|
137
|
+
head: ["Org", "Label", "Default", "Workspaces"],
|
|
138
|
+
style: { head: ["cyan"] },
|
|
139
|
+
});
|
|
140
|
+
for (const r of shown)
|
|
141
|
+
table.push([
|
|
142
|
+
chalk.bold(r.id),
|
|
143
|
+
r.label,
|
|
144
|
+
r.default ? chalk.green("✓") : "",
|
|
145
|
+
r.workspaces.join(", ") || chalk.gray("none"),
|
|
146
|
+
]);
|
|
147
|
+
console.log(table.toString());
|
|
148
|
+
if (only && shown.length === 0)
|
|
149
|
+
console.log(chalk.yellow(` No org ${only}.`));
|
|
150
|
+
const inScope = only
|
|
151
|
+
? new Set(
|
|
152
|
+
resolved.filter((w) => sameOrg(w.org, only)).map((w) => w.workspace),
|
|
153
|
+
)
|
|
154
|
+
: null;
|
|
155
|
+
printOrgWarnings(
|
|
156
|
+
inScope
|
|
157
|
+
? warnings.filter((w) =>
|
|
158
|
+
[...inScope].some((n) => w.startsWith(`workspace ${n}:`)),
|
|
159
|
+
)
|
|
160
|
+
: warnings,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Display test results in CLI
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { newTable } from "./table.js";
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { resultReader } from "../../shared/results/result-reader.js";
|
|
9
9
|
import type {
|
|
@@ -40,7 +40,7 @@ export class ResultsViewer {
|
|
|
40
40
|
for (const pillar of pillars) {
|
|
41
41
|
console.log(chalk.bold(`\n${this.formatPillarName(pillar)}:`));
|
|
42
42
|
|
|
43
|
-
const table =
|
|
43
|
+
const table = newTable({
|
|
44
44
|
head: [
|
|
45
45
|
chalk.cyan("Environment"),
|
|
46
46
|
chalk.cyan("Total"),
|
|
@@ -115,7 +115,7 @@ export class ResultsViewer {
|
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
const table =
|
|
118
|
+
const table = newTable({
|
|
119
119
|
head: [
|
|
120
120
|
chalk.cyan("Test"),
|
|
121
121
|
chalk.cyan("Status"),
|
|
@@ -191,7 +191,7 @@ export class ResultsViewer {
|
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
const table =
|
|
194
|
+
const table = newTable({
|
|
195
195
|
head: [
|
|
196
196
|
chalk.cyan("Journey"),
|
|
197
197
|
chalk.cyan("Verdict"),
|
|
@@ -281,7 +281,7 @@ export class ResultsViewer {
|
|
|
281
281
|
return;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
const table =
|
|
284
|
+
const table = newTable({
|
|
285
285
|
head: [
|
|
286
286
|
chalk.cyan("Test"),
|
|
287
287
|
chalk.cyan("Runs"),
|
|
@@ -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
|
+
}
|