@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-table3 colours its borders (grey) and head cells (its own default, or
|
|
3
|
+
* the `style` given) through @colors/colors, which ignores Den's colour
|
|
4
|
+
* detection: piped `status` output carried ANSI codes from the table alone.
|
|
5
|
+
* Every CLI table goes through here, and takes no colour when chalk has none
|
|
6
|
+
* (not a TTY, NO_COLOR, FORCE_COLOR=0).
|
|
7
|
+
*/
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import Table from "cli-table3";
|
|
10
|
+
|
|
11
|
+
export function newTable(
|
|
12
|
+
opts: Table.TableConstructorOptions = {},
|
|
13
|
+
colour: boolean = chalk.level > 0,
|
|
14
|
+
): Table.Table {
|
|
15
|
+
if (colour) return new Table(opts);
|
|
16
|
+
return new Table({
|
|
17
|
+
...opts,
|
|
18
|
+
style: { ...(opts.style ?? {}), head: [], border: [] },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare `<cli> validate` in the distribution root: every manifest the
|
|
3
|
+
* distribution uses, without cloning anything.
|
|
4
|
+
*
|
|
5
|
+
* - Each repo of every workspace, once per distinct checkout path: its
|
|
6
|
+
* layered manifest (own or held, then the override, then the merge).
|
|
7
|
+
* - A repo with no checkout on disk (a managed clone not made yet, a
|
|
8
|
+
* dev-managed path that is gone) falls back to its held manifest, marked
|
|
9
|
+
* "held only, no checkout"; with no held manifest either it is a warning,
|
|
10
|
+
* not a failure.
|
|
11
|
+
* - Each held manifest (and override) in manifests/ that no repo above used.
|
|
12
|
+
*
|
|
13
|
+
* The layered validator comes in as `validate` (validateManifestLayers), so
|
|
14
|
+
* this file stays under the typecheck gate (see validate-layers.ts).
|
|
15
|
+
*
|
|
16
|
+
* `--held` checks only manifests/, as if nothing were checked out: a result
|
|
17
|
+
* that does not depend on the machine (an upgrade gate).
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { product } from "../../shared/product.js";
|
|
22
|
+
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
23
|
+
import {
|
|
24
|
+
readWorkspacesFile,
|
|
25
|
+
resolveServiceSource,
|
|
26
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
27
|
+
import {
|
|
28
|
+
heldManifestsDir,
|
|
29
|
+
overrideSuffix,
|
|
30
|
+
} from "../../shared/manifest/read-manifest.js";
|
|
31
|
+
import type { LayeredValidation } from "../../shared/manifest/validate-layers.js";
|
|
32
|
+
|
|
33
|
+
export interface RepoValidation {
|
|
34
|
+
repo: string;
|
|
35
|
+
/** The checkout validated, or null when only manifests/ was read. */
|
|
36
|
+
checkout: string | null;
|
|
37
|
+
/** Workspaces that use this checkout. */
|
|
38
|
+
workspaces: string[];
|
|
39
|
+
/** own | held, plus "+ override" when one layered on. */
|
|
40
|
+
source: string;
|
|
41
|
+
/** Set when there was no checkout and the held manifest stood in. */
|
|
42
|
+
note?: string;
|
|
43
|
+
/** No checkout and no held manifest: reported, not failed. */
|
|
44
|
+
missing?: boolean;
|
|
45
|
+
result: LayeredValidation;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A path that never exists, named after the repo, so the layer lookup finds
|
|
50
|
+
* only manifests/<repo>… and never a checkout's own manifest.
|
|
51
|
+
*/
|
|
52
|
+
function heldOnlyPath(repo: string): string {
|
|
53
|
+
return path.join(heldManifestsDir(), ".held-only", repo);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sourceOf(result: LayeredValidation): string {
|
|
57
|
+
const base = result.layers.find(
|
|
58
|
+
(l) => l.layer === "repo" || l.layer === "held",
|
|
59
|
+
);
|
|
60
|
+
const own = base?.layer === "repo" ? "own" : base ? "held" : "none";
|
|
61
|
+
return result.layers.some((l) => l.layer === "override")
|
|
62
|
+
? `${own} + override`
|
|
63
|
+
: own;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The repo keys manifests/ holds a manifest or an override for. */
|
|
67
|
+
function heldKeys(): string[] {
|
|
68
|
+
const dir = heldManifestsDir();
|
|
69
|
+
if (!fs.existsSync(dir)) return [];
|
|
70
|
+
const yml = product().manifestYml;
|
|
71
|
+
const over = overrideSuffix();
|
|
72
|
+
const keys = new Set<string>();
|
|
73
|
+
for (const name of fs.readdirSync(dir)) {
|
|
74
|
+
if (name.endsWith(over)) keys.add(name.slice(0, -over.length));
|
|
75
|
+
else if (name.endsWith(yml)) keys.add(name.slice(0, -yml.length));
|
|
76
|
+
}
|
|
77
|
+
return [...keys].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Validate = (repoPath: string) => LayeredValidation;
|
|
81
|
+
|
|
82
|
+
function heldOnly(repo: string, validate: Validate): RepoValidation {
|
|
83
|
+
const result = validate(heldOnlyPath(repo));
|
|
84
|
+
return {
|
|
85
|
+
repo,
|
|
86
|
+
checkout: null,
|
|
87
|
+
workspaces: [],
|
|
88
|
+
source: sourceOf(result),
|
|
89
|
+
result,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateAll(opts: {
|
|
94
|
+
held?: boolean;
|
|
95
|
+
validate: Validate;
|
|
96
|
+
}): RepoValidation[] {
|
|
97
|
+
const { validate } = opts;
|
|
98
|
+
if (opts.held)
|
|
99
|
+
return heldKeys().map((k) => {
|
|
100
|
+
const r = heldOnly(k, validate);
|
|
101
|
+
// An override with no held base layers onto the repo's own manifest,
|
|
102
|
+
// which --held deliberately does not read: noted, not failed.
|
|
103
|
+
const overrideOnly =
|
|
104
|
+
r.result.layers.length > 0 &&
|
|
105
|
+
r.result.layers.every((l) => l.layer === "override");
|
|
106
|
+
return overrideOnly
|
|
107
|
+
? {
|
|
108
|
+
...r,
|
|
109
|
+
source: "override",
|
|
110
|
+
missing: true,
|
|
111
|
+
note: "override only: its base is the repo's own manifest (not read with --held)",
|
|
112
|
+
}
|
|
113
|
+
: r;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const file = readWorkspacesFile();
|
|
117
|
+
const catalog = readCatalog();
|
|
118
|
+
// repo + checkout path → the workspaces that use it.
|
|
119
|
+
const seen = new Map<
|
|
120
|
+
string,
|
|
121
|
+
{ repo: string; path: string; workspaces: string[] }
|
|
122
|
+
>();
|
|
123
|
+
for (const [ws, config] of Object.entries(file.workspaces)) {
|
|
124
|
+
for (const address of Object.keys(config.services ?? {})) {
|
|
125
|
+
const src = resolveServiceSource(address, ws, config, catalog);
|
|
126
|
+
const repo = src?.repoName ?? address.split("/")[0];
|
|
127
|
+
const p = src?.repoPath ? path.resolve(src.repoPath) : "";
|
|
128
|
+
const key = `${repo}\0${p}`;
|
|
129
|
+
const hit = seen.get(key);
|
|
130
|
+
if (hit) {
|
|
131
|
+
if (!hit.workspaces.includes(ws)) hit.workspaces.push(ws);
|
|
132
|
+
} else seen.set(key, { repo, path: p, workspaces: [ws] });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const out: RepoValidation[] = [];
|
|
137
|
+
const used = new Set<string>();
|
|
138
|
+
for (const { repo, path: p, workspaces } of seen.values()) {
|
|
139
|
+
used.add(repo);
|
|
140
|
+
if (p && fs.existsSync(p)) {
|
|
141
|
+
const result = validate(p);
|
|
142
|
+
out.push({
|
|
143
|
+
repo,
|
|
144
|
+
checkout: p,
|
|
145
|
+
workspaces,
|
|
146
|
+
source: sourceOf(result),
|
|
147
|
+
result,
|
|
148
|
+
...(result.layers.length === 0 ? { missing: true } : {}),
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const held = heldOnly(repo, validate);
|
|
153
|
+
out.push({
|
|
154
|
+
...held,
|
|
155
|
+
checkout: p || null,
|
|
156
|
+
workspaces,
|
|
157
|
+
...(held.result.layers.length
|
|
158
|
+
? { note: "held only, no checkout" }
|
|
159
|
+
: { missing: true, note: "no checkout and no held manifest" }),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
for (const key of heldKeys())
|
|
163
|
+
if (!used.has(key))
|
|
164
|
+
out.push({ ...heldOnly(key, validate), note: "held, in no workspace" });
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether the run fails: any layer invalid, ignoring `missing` entries. */
|
|
169
|
+
export function validateAllFailed(results: RepoValidation[]): boolean {
|
|
170
|
+
return results.some((r) => !r.missing && !r.result.valid);
|
|
171
|
+
}
|
|
@@ -13,6 +13,12 @@ import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
|
|
|
13
13
|
import { hasDenUiMarker } from "../../shared/ui-marker.js";
|
|
14
14
|
import { engineBin, engineModuleFile } from "../../shared/engine.js";
|
|
15
15
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
16
|
+
import {
|
|
17
|
+
ownServer,
|
|
18
|
+
processCwd,
|
|
19
|
+
refuseForeignServer,
|
|
20
|
+
samePath,
|
|
21
|
+
} from "./server-identity.js";
|
|
16
22
|
|
|
17
23
|
import { product } from "../../shared/product.js";
|
|
18
24
|
interface WebCommandOptions {
|
|
@@ -569,21 +575,55 @@ async function waitForPidsExit(pids: number[]): Promise<boolean> {
|
|
|
569
575
|
return false;
|
|
570
576
|
}
|
|
571
577
|
|
|
572
|
-
|
|
578
|
+
/**
|
|
579
|
+
* Whether `pid` may be this distribution's server by where it runs: a web
|
|
580
|
+
* server runs from its distribution root, so one whose working directory
|
|
581
|
+
* is another root is never ours, even when its command line matches (two
|
|
582
|
+
* distributions on one linked engine run the same entry point). An
|
|
583
|
+
* unreadable directory does not decide.
|
|
584
|
+
*/
|
|
585
|
+
function runsFromRoot(pid: number, root: string): boolean {
|
|
586
|
+
const cwd = processCwd(pid);
|
|
587
|
+
if (!cwd) return true;
|
|
588
|
+
return samePath(cwd, root);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function isOurWebPid(pid: number, root: string): boolean {
|
|
592
|
+
const command = commandForPid(pid);
|
|
593
|
+
return (
|
|
594
|
+
!!command &&
|
|
595
|
+
isDenWebCommand(command, webRoots(root)) &&
|
|
596
|
+
runsFromRoot(pid, root)
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Our server's pids on `port`. `vouched` is the pid of a server the
|
|
602
|
+
* identity check (server-info: our root, or an older server run from our
|
|
603
|
+
* root) found ours: it is stopped when it listens on the port even if its
|
|
604
|
+
* command line names no entry point we recognise. Before, an older Den
|
|
605
|
+
* installed elsewhere, or a hand-started server, matched no command line,
|
|
606
|
+
* so `web down` said "not running" and `web restart` "already running".
|
|
607
|
+
*/
|
|
608
|
+
function findDenWebPids(port: number, vouched?: number): number[] {
|
|
573
609
|
const root = getDistRoot();
|
|
574
610
|
const pids = new Set<number>();
|
|
575
611
|
const state = readWebServerState();
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
612
|
+
const listeners = listListeningPids(port);
|
|
613
|
+
// The state file may be shared by distributions of one product name: a
|
|
614
|
+
// record of another root's server is not ours to stop.
|
|
615
|
+
if (
|
|
616
|
+
state?.port === port &&
|
|
617
|
+
state.pid &&
|
|
618
|
+
samePath(state.root || root, root) &&
|
|
619
|
+
(isOurWebPid(state.pid, root) ||
|
|
620
|
+
(state.pid === vouched && listeners.includes(state.pid)))
|
|
621
|
+
) {
|
|
622
|
+
pids.add(state.pid);
|
|
581
623
|
}
|
|
582
624
|
|
|
583
|
-
for (const pid of
|
|
584
|
-
if (
|
|
585
|
-
pids.add(pid);
|
|
586
|
-
}
|
|
625
|
+
for (const pid of listeners) {
|
|
626
|
+
if (pid === vouched || isOurWebPid(pid, root)) pids.add(pid);
|
|
587
627
|
}
|
|
588
628
|
|
|
589
629
|
return Array.from(pids);
|
|
@@ -611,8 +651,11 @@ export function killWebProcess(pid: number, signal: NodeJS.Signals): void {
|
|
|
611
651
|
}
|
|
612
652
|
}
|
|
613
653
|
|
|
614
|
-
async function stopDenWebServer(
|
|
615
|
-
|
|
654
|
+
async function stopDenWebServer(
|
|
655
|
+
port: number,
|
|
656
|
+
vouched?: number,
|
|
657
|
+
): Promise<StopServerResult> {
|
|
658
|
+
const pids = findDenWebPids(port, vouched);
|
|
616
659
|
if (pids.length === 0) {
|
|
617
660
|
const state = readWebServerState();
|
|
618
661
|
if (state?.port === port) removeWebServerState();
|
|
@@ -636,22 +679,25 @@ async function stopDenWebServer(port: number): Promise<StopServerResult> {
|
|
|
636
679
|
return { stopped: forced, forced: true, pids };
|
|
637
680
|
}
|
|
638
681
|
|
|
639
|
-
async function stopBrokenDenWebServer(
|
|
640
|
-
|
|
682
|
+
async function stopBrokenDenWebServer(
|
|
683
|
+
port: number,
|
|
684
|
+
vouched?: number,
|
|
685
|
+
): Promise<boolean> {
|
|
686
|
+
return (await stopDenWebServer(port, vouched)).stopped;
|
|
641
687
|
}
|
|
642
688
|
|
|
643
689
|
function recordRunningDenWebServer(port: number): void {
|
|
644
690
|
const root = getDistRoot();
|
|
645
691
|
const state = readWebServerState();
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
692
|
+
if (
|
|
693
|
+
state?.port === port &&
|
|
694
|
+
state.pid &&
|
|
695
|
+
samePath(state.root || root, root) &&
|
|
696
|
+
isOurWebPid(state.pid, root)
|
|
697
|
+
)
|
|
698
|
+
return;
|
|
651
699
|
|
|
652
|
-
const pids = listListeningPids(port).filter((pid) =>
|
|
653
|
-
isDenWebCommand(commandForPid(pid), webRoots(root)),
|
|
654
|
-
);
|
|
700
|
+
const pids = listListeningPids(port).filter((pid) => isOurWebPid(pid, root));
|
|
655
701
|
const pid = pids[0];
|
|
656
702
|
if (!pid) return;
|
|
657
703
|
|
|
@@ -965,8 +1011,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
965
1011
|
|
|
966
1012
|
const url = buildDenWebUrl(options.port);
|
|
967
1013
|
|
|
1014
|
+
// Never act on another distribution's server: not "already running", and
|
|
1015
|
+
// never a signal to it (down, restart).
|
|
1016
|
+
const identity = await refuseForeignServer(options.port, ownServer());
|
|
1017
|
+
const vouched = identity.kind === "ours" ? identity.pid : undefined;
|
|
1018
|
+
|
|
968
1019
|
if (options.action === "down") {
|
|
969
|
-
const stopped = await stopDenWebServer(options.port);
|
|
1020
|
+
const stopped = await stopDenWebServer(options.port, vouched);
|
|
970
1021
|
if (stopped.stopped) {
|
|
971
1022
|
const mode = stopped.forced ? "force stopped" : "stopped";
|
|
972
1023
|
console.log(
|
|
@@ -988,7 +1039,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
988
1039
|
}
|
|
989
1040
|
|
|
990
1041
|
if (options.action === "restart") {
|
|
991
|
-
const stopped = await stopDenWebServer(options.port);
|
|
1042
|
+
const stopped = await stopDenWebServer(options.port, vouched);
|
|
992
1043
|
if (stopped.pids.length > 0 && !stopped.stopped) {
|
|
993
1044
|
throw new Error(
|
|
994
1045
|
`Could not stop ${product().title} web on port ${options.port}.`,
|
|
@@ -1015,7 +1066,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
|
|
|
1015
1066
|
),
|
|
1016
1067
|
);
|
|
1017
1068
|
console.log(chalk.gray(` Restarting ${product().title} web...`));
|
|
1018
|
-
const stopped = await stopBrokenDenWebServer(options.port);
|
|
1069
|
+
const stopped = await stopBrokenDenWebServer(options.port, vouched);
|
|
1019
1070
|
if (!stopped) {
|
|
1020
1071
|
throw new Error(
|
|
1021
1072
|
`Could not stop the broken ${product().title} web server on port ${options.port}.`,
|