@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.
- package/CHANGELOG.md +195 -0
- package/docs/distributions.md +158 -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/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- 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 +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- 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/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- 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 +120 -40
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- 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
|
|
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
|
+
}
|
|
@@ -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")
|
|
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
|
-
/**
|
|
78
|
-
|
|
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
|
-
//
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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()
|