@monster-orange/den 0.1.0 → 0.2.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +16 -1
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +321 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Which repos a sync may touch. Sync discards local changes (`git restore .`)
3
+ * and fast-forwards, so it runs on managed clones only: never on a
4
+ * dev-managed checkout, the engineer's own, which Den never modifies. That
5
+ * holds even when a managed entry somewhere resolves to the same path as a
6
+ * dev-managed one (or to a directory holding or inside one).
7
+ */
8
+ import * as path from "path";
9
+ import type { ResolvedService, WorkspacesFile } from "../types/index.js";
10
+
11
+ /** Every workspace's dev-managed checkout paths, resolved. */
12
+ export function devManagedPaths(wsFile: WorkspacesFile): Set<string> {
13
+ const out = new Set<string>();
14
+ for (const ws of Object.values(wsFile.workspaces ?? {}))
15
+ for (const entry of Object.values(ws.services ?? {}))
16
+ if (entry.mode === "dev-managed" && entry.path)
17
+ out.add(path.resolve(entry.path));
18
+ return out;
19
+ }
20
+
21
+ function overlaps(a: string, b: string): boolean {
22
+ return a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
23
+ }
24
+
25
+ export interface SyncSelection {
26
+ targets: ResolvedService[];
27
+ /** Repos left alone, with why (reported, never silently skipped). */
28
+ refused: { repoName: string; reason: string }[];
29
+ }
30
+
31
+ export function selectSyncTargets(
32
+ services: readonly ResolvedService[],
33
+ opts: { onlyRepo?: string | null; devManaged: ReadonlySet<string> },
34
+ ): SyncSelection {
35
+ const targets: ResolvedService[] = [];
36
+ const refused: SyncSelection["refused"] = [];
37
+ const seen = new Set<string>();
38
+ const refuse = (repoName: string, reason: string): void => {
39
+ if (!refused.some((r) => r.repoName === repoName))
40
+ refused.push({ repoName, reason });
41
+ };
42
+ for (const s of services) {
43
+ if (opts.onlyRepo && s.repoName !== opts.onlyRepo) continue;
44
+ if (s.mode !== "managed") {
45
+ if (opts.onlyRepo)
46
+ refuse(
47
+ s.repoName,
48
+ "it is dev-managed (your own checkout); sync only updates managed clones",
49
+ );
50
+ continue;
51
+ }
52
+ const repoPath = path.resolve(s.repoPath);
53
+ if (seen.has(repoPath)) continue;
54
+ seen.add(repoPath);
55
+ const clash = [...opts.devManaged].find((p) => overlaps(repoPath, p));
56
+ if (clash) {
57
+ refuse(
58
+ s.repoName,
59
+ `its path ${repoPath} overlaps the dev-managed checkout ${clash}; sync never touches one`,
60
+ );
61
+ continue;
62
+ }
63
+ targets.push(s);
64
+ }
65
+ return { targets, refused };
66
+ }
@@ -29,11 +29,12 @@ import { resolveService } from "./service-runner.js";
29
29
  import { readManifest } from "../manifest/read-manifest.js";
30
30
  import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
31
31
  import { getFocusedWorkspaceName } from "./workspaces.js";
32
+ import { placementRefusal, servicePlacement } from "../manifest/placement.js";
32
33
  import { listMakeTargets } from "./make-targets.js";
33
34
  import { devOpContainerNames } from "./opctl.js";
34
35
 
35
36
  import { product } from "../product.js";
36
- export type UpFlag = "auto-start off" | "interactive" | "no-command";
37
+ export type UpFlag = "auto-start off" | "interactive" | "no-command" | "remote";
37
38
 
38
39
  export interface NodePlan {
39
40
  repo: string;
@@ -264,6 +265,29 @@ export function buildUpPlan(
264
265
  };
265
266
  }
266
267
 
268
+ // R13: a remote service is never started, whatever its Makefile says.
269
+ const remote = placementRefusal(
270
+ svc.address,
271
+ servicePlacement(readManifest(svc.repoPath), svc.subsystem, "local"),
272
+ () => false,
273
+ );
274
+ if (remote) {
275
+ return {
276
+ repo,
277
+ upCommand: null,
278
+ upSource: "none",
279
+ containerNames: [],
280
+ healthUrl: null,
281
+ noAutostart: noAutostartSet.has(repo),
282
+ interactive: false,
283
+ interactiveReason: "",
284
+ interactiveKind: "",
285
+ defaultAction: "skip",
286
+ flags: ["remote"],
287
+ notes: [remote],
288
+ };
289
+ }
290
+
267
291
  const { command, source, overrideAvailable } = resolveUpCommand(svc);
268
292
  if (overrideAvailable) {
269
293
  notes.push(
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The manifest override layer: `manifests/<repo>.override.yml` deep-merged
3
+ * onto whichever base manifest won (the repo's own, or the held one).
4
+ *
5
+ * Merge rules:
6
+ * - maps merge by key, recursively;
7
+ * - lists and scalars replace the base value whole;
8
+ * - an explicit `null` deletes the key from the result.
9
+ *
10
+ * An override only changes what the base already has: a key (or service)
11
+ * the base lacks is an error (see overrideKeyErrors), so a typo can never
12
+ * add a silent dead key. The one exception is a service's placement (`runs`,
13
+ * `target`), which an override may add to a service the base has.
14
+ */
15
+
16
+ import type { ValidationError } from "../types/index.js";
17
+
18
+ export type ManifestMap = Record<string, unknown>;
19
+
20
+ /** A plain YAML/JSON map (not a list, not null). */
21
+ export function isPlainMap(v: unknown): v is ManifestMap {
22
+ return v !== null && typeof v === "object" && !Array.isArray(v);
23
+ }
24
+
25
+ /** `override` deep-merged onto `base`. Neither input is changed. */
26
+ export function mergeOverride(
27
+ base: ManifestMap,
28
+ override: ManifestMap,
29
+ ): ManifestMap {
30
+ const out: ManifestMap = { ...base };
31
+ for (const [key, value] of Object.entries(override)) {
32
+ if (value === null) delete out[key];
33
+ else if (isPlainMap(value) && isPlainMap(out[key]))
34
+ out[key] = mergeOverride(out[key] as ManifestMap, value);
35
+ else out[key] = value;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Every key in `override` the base lacks, by its dotted path. A whole
42
+ * service the base lacks is reported as one error at `services.<name>`.
43
+ */
44
+ /**
45
+ * Keys an override may add to a service the base has: placement (R13) is
46
+ * what a distribution most often needs to differ, and is never a dead key.
47
+ */
48
+ const ADDABLE_SERVICE_KEYS = new Set(["runs", "target"]);
49
+
50
+ export function overrideKeyErrors(
51
+ base: ManifestMap,
52
+ override: ManifestMap,
53
+ at: string[] = [],
54
+ ): ValidationError[] {
55
+ const errors: ValidationError[] = [];
56
+ const inService = at.length === 2 && at[0] === "services";
57
+ for (const [key, value] of Object.entries(override)) {
58
+ const here = [...at, key];
59
+ if (!(key in base) && inService && ADDABLE_SERVICE_KEYS.has(key)) continue;
60
+ if (!(key in base)) {
61
+ const isService = at.length === 1 && at[0] === "services";
62
+ errors.push({
63
+ path: here.join("."),
64
+ message: isService
65
+ ? `unknown service "${key}": the base manifest has no such service`
66
+ : `unknown key "${key}": not in the base manifest`,
67
+ suggestion:
68
+ "An override can only change or delete what the base manifest has.",
69
+ });
70
+ } else if (isPlainMap(value) && isPlainMap(base[key])) {
71
+ errors.push(...overrideKeyErrors(base[key] as ManifestMap, value, here));
72
+ }
73
+ }
74
+ return errors;
75
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Placement (R13): where each service runs for the env being viewed.
3
+ *
4
+ * services:
5
+ * core:
6
+ * runs: { local: remote, dev: remote } # per env: local | remote
7
+ * target: { env: dev, account: AWS, url: https://…/health, expect: 401 }
8
+ *
9
+ * `runs` is "local" (on this machine), "remote" (deployed elsewhere: Den never
10
+ * starts, stops, scans or execs it) or "none" (no runtime Den knows about).
11
+ * When the manifest does not say, for the viewed env:
12
+ * - local if the service has commands there (locally: or the repo is a
13
+ * compose stack);
14
+ * - remote if it has an HTTP health URL there and no commands;
15
+ * - none otherwise.
16
+ *
17
+ * `target` is set only for a remote service. The manifest's target applies
18
+ * when viewing locally or its own env; any other env stands for itself.
19
+ *
20
+ * Pure: callers pass the manifest, and the production test where it matters.
21
+ */
22
+
23
+ import type {
24
+ RawManifest,
25
+ ServiceHealthConfig,
26
+ ServiceManagementConfig,
27
+ ValidationError,
28
+ } from "../types/index.js";
29
+
30
+ export type Runs = "local" | "remote" | "none";
31
+
32
+ /** What /api/services, /api/health and /api/applications serve. */
33
+ export interface PlacementTarget {
34
+ env: string;
35
+ account?: string;
36
+ url?: string;
37
+ }
38
+
39
+ export interface Placement {
40
+ runs: Runs;
41
+ target: PlacementTarget | null;
42
+ }
43
+
44
+ /** Status codes that count as up for a target.url probe. */
45
+ export type Expect = readonly number[] | "2xx";
46
+
47
+ /** Is this env treated as production by the org? */
48
+ export type IsProduction = (env: string) => boolean;
49
+
50
+ const LOCAL = "local";
51
+
52
+ function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
53
+ return !!h && h.type !== "shell" && typeof h.url === "string";
54
+ }
55
+
56
+ function hasCommands(
57
+ manifest: RawManifest,
58
+ svc: ServiceManagementConfig,
59
+ env: string,
60
+ ): boolean {
61
+ if (ownCommands(svc, env)) return true;
62
+ if (env !== LOCAL) return false;
63
+ // Every service of a compose repo runs in the local stack, and so does
64
+ // every service of a repo whose own up runs here: one repo-level up starts
65
+ // services that declare no commands of their own.
66
+ return (
67
+ !!manifest.compose ||
68
+ Object.values(manifest.services ?? {}).some((s) => ownCommands(s, env))
69
+ );
70
+ }
71
+
72
+ function ownCommands(svc: ServiceManagementConfig, env: string): boolean {
73
+ const cmds = svc.commands?.[env as keyof typeof svc.commands];
74
+ return !!cmds && Object.keys(cmds).length > 0;
75
+ }
76
+
77
+ function targetFor(svc: ServiceManagementConfig, env: string): PlacementTarget {
78
+ const t = svc.target;
79
+ const own = t && (env === LOCAL || t.env === env) ? t : undefined;
80
+ const tEnv = own?.env ?? env;
81
+ const health = svc.health?.[tEnv as keyof typeof svc.health];
82
+ const url = own?.url ?? (isHttp(health) ? health.url : undefined);
83
+ return {
84
+ env: tEnv,
85
+ ...(own?.account ? { account: own.account } : {}),
86
+ ...(url ? { url } : {}),
87
+ };
88
+ }
89
+
90
+ /** Where `service` of `manifest` runs when viewing `env`. */
91
+ export function servicePlacement(
92
+ manifest: RawManifest | null | undefined,
93
+ service: string,
94
+ env: string,
95
+ ): Placement {
96
+ const svc = manifest?.services?.[service];
97
+ if (!manifest || !svc) return { runs: "none", target: null };
98
+ const explicit = svc.runs?.[env as keyof typeof svc.runs];
99
+ if (explicit === "local") return { runs: "local", target: null };
100
+ if (explicit === "remote")
101
+ return { runs: "remote", target: targetFor(svc, env) };
102
+ if (hasCommands(manifest, svc, env)) return { runs: "local", target: null };
103
+ if (isHttp(svc.health?.[env as keyof typeof svc.health]))
104
+ return { runs: "remote", target: targetFor(svc, env) };
105
+ return { runs: "none", target: null };
106
+ }
107
+
108
+ /** "dev · AWS": where a remote service is, for labels. */
109
+ export function targetLabel(t: PlacementTarget): string {
110
+ return t.account ? `${t.env} · ${t.account}` : t.env;
111
+ }
112
+
113
+ /** "☁ remote (dev)" for a remote service; "" otherwise. */
114
+ export function placementSuffix(p: Placement): string {
115
+ return p.runs === "remote" && p.target ? `☁ remote (${p.target.env})` : "";
116
+ }
117
+
118
+ // ── Health ──────────────────────────────────────────────────────────────────
119
+
120
+ export interface HealthCheckPlan {
121
+ placement: Placement;
122
+ /** The check to run, or undefined for none. */
123
+ check?: ServiceHealthConfig;
124
+ /** Set when probing target.url: the codes that count as up. */
125
+ expect?: Expect;
126
+ /** A remote service's row label: its target ("dev · AWS"). */
127
+ label?: string;
128
+ }
129
+
130
+ function expectOf(e: number | number[] | undefined): Expect {
131
+ if (e === undefined) return "2xx";
132
+ return Array.isArray(e) ? e : [e];
133
+ }
134
+
135
+ /**
136
+ * The health check for `service` viewed in `env`. The one resolution path for
137
+ * the web health row and `<cli> status`.
138
+ * - local / none: the env's declared check, else `composeShell` (locally);
139
+ * - remote: the check declared for target.env, else a GET of target.url
140
+ * (up when the status is in target.expect, default any 2xx).
141
+ */
142
+ export function resolveHealthCheck(
143
+ manifest: RawManifest | null | undefined,
144
+ service: string,
145
+ env: string,
146
+ composeShell: string | null,
147
+ ): HealthCheckPlan {
148
+ const placement = servicePlacement(manifest, service, env);
149
+ const svc = manifest?.services?.[service];
150
+ if (placement.runs === "remote" && placement.target) {
151
+ const t = placement.target;
152
+ const label = targetLabel(t);
153
+ const declared = svc?.health?.[t.env as keyof typeof svc.health];
154
+ if (declared) return { placement, check: declared, label };
155
+ if (svc?.target?.url && t.url === svc.target.url)
156
+ return {
157
+ placement,
158
+ check: { type: "http", url: t.url },
159
+ expect: expectOf(svc.target.expect),
160
+ label,
161
+ };
162
+ return { placement, label };
163
+ }
164
+ const declared = svc?.health?.[env as keyof typeof svc.health];
165
+ if (declared) return { placement, check: declared };
166
+ if (composeShell)
167
+ return { placement, check: { type: "shell", cmd: composeShell } };
168
+ return { placement };
169
+ }
170
+
171
+ /** Whether `status` counts as up under `expect`. */
172
+ export function expectMatches(
173
+ status: number | undefined,
174
+ expect: Expect,
175
+ ): boolean {
176
+ if (status === undefined) return false;
177
+ return expect === "2xx"
178
+ ? status >= 200 && status < 300
179
+ : expect.includes(status);
180
+ }
181
+
182
+ // ── Guards ──────────────────────────────────────────────────────────────────
183
+
184
+ /** A remote service whose target env counts as production. */
185
+ export function isProtectedRemote(p: Placement, isProduction: IsProduction) {
186
+ return p.runs === "remote" && !!p.target && isProduction(p.target.env);
187
+ }
188
+
189
+ /**
190
+ * Why Den must not run a command on `address`, or null. Every command on a
191
+ * remote service is refused ("runs in dev (AWS); nothing to start here").
192
+ */
193
+ export function placementRefusal(
194
+ address: string,
195
+ p: Placement,
196
+ isProduction: IsProduction,
197
+ verb: "start" | "stop" = "start",
198
+ ): string | null {
199
+ if (p.runs !== "remote") return null;
200
+ const where = p.target
201
+ ? `${p.target.env}${p.target.account ? ` (${p.target.account})` : ""}`
202
+ : "a remote env";
203
+ const prod = isProtectedRemote(p, isProduction)
204
+ ? ", which counts as production: it is never acted on from here"
205
+ : "";
206
+ return `${address} runs in ${where}; nothing to ${verb} here${prod}`;
207
+ }
208
+
209
+ // ── Rollups ─────────────────────────────────────────────────────────────────
210
+
211
+ export type RollupPlacement = Runs | "hybrid";
212
+
213
+ export interface PlacementRow extends Placement {
214
+ repo: string;
215
+ service: string;
216
+ }
217
+
218
+ function rollup(runs: Runs[]): RollupPlacement {
219
+ const set = new Set(runs.filter((r) => r !== "none"));
220
+ if (set.size === 0) return "none";
221
+ return set.size > 1 ? "hybrid" : [...set][0];
222
+ }
223
+
224
+ function envsOf(rows: Placement[]): string[] {
225
+ return [
226
+ ...new Set(
227
+ rows.flatMap((r) =>
228
+ r.runs === "remote" && r.target ? [r.target.env] : [],
229
+ ),
230
+ ),
231
+ ].sort();
232
+ }
233
+
234
+ /**
235
+ * An application's placement and a summary per repo, e.g.
236
+ * "shop-frontend local · shop-core remote (dev)".
237
+ */
238
+ export function rollupPlacement(rows: readonly PlacementRow[]): {
239
+ placement: RollupPlacement;
240
+ summary: string;
241
+ } {
242
+ const byRepo = new Map<string, PlacementRow[]>();
243
+ for (const r of rows) {
244
+ if (!byRepo.has(r.repo)) byRepo.set(r.repo, []);
245
+ byRepo.get(r.repo)!.push(r);
246
+ }
247
+ const parts: string[] = [];
248
+ for (const [repo, list] of byRepo) {
249
+ const p = rollup(list.map((r) => r.runs));
250
+ if (p === "none") continue;
251
+ if (p === "local") parts.push(`${repo} local`);
252
+ else if (p === "remote")
253
+ parts.push(`${repo} remote (${envsOf(list).join(", ")})`);
254
+ else
255
+ parts.push(
256
+ `${repo} hybrid (${list
257
+ .filter((r) => r.runs === "remote")
258
+ .map((r) => `${r.service} remote (${r.target?.env ?? "?"})`)
259
+ .join(", ")})`,
260
+ );
261
+ }
262
+ return {
263
+ placement: rollup(rows.map((r) => r.runs)),
264
+ summary: parts.join(" · "),
265
+ };
266
+ }
267
+
268
+ /** A repo's placement over its manifest's services, and its remote envs. */
269
+ export function repoPlacement(
270
+ manifest: RawManifest | null | undefined,
271
+ env: string,
272
+ ): { placement: RollupPlacement; envs: string[] } {
273
+ const list = Object.keys(manifest?.services ?? {}).map((s) =>
274
+ servicePlacement(manifest, s, env),
275
+ );
276
+ return { placement: rollup(list.map((p) => p.runs)), envs: envsOf(list) };
277
+ }
278
+
279
+ // ── Validation ──────────────────────────────────────────────────────────────
280
+
281
+ function isMap(v: unknown): v is Record<string, unknown> {
282
+ return v !== null && typeof v === "object" && !Array.isArray(v);
283
+ }
284
+
285
+ /**
286
+ * Placement errors the schema cannot see: env names in `runs` and
287
+ * `target.env` must be among `environments` (the org's), and a service run
288
+ * remote locally must say where (`target`).
289
+ */
290
+ export function placementErrors(
291
+ manifest: unknown,
292
+ environments: readonly string[],
293
+ ): ValidationError[] {
294
+ if (!isMap(manifest) || !isMap(manifest.services)) return [];
295
+ const known = environments.join(", ");
296
+ const unknownEnv = (path: string, env: string): ValidationError => ({
297
+ path,
298
+ message: `unknown environment "${env}" (known: ${known})`,
299
+ suggestion: "Use one of the org's environments (config `environments:`).",
300
+ });
301
+ const errors: ValidationError[] = [];
302
+ for (const [name, svc] of Object.entries(manifest.services)) {
303
+ if (!isMap(svc)) continue;
304
+ const at = `services.${name}`;
305
+ const runs = isMap(svc.runs) ? svc.runs : {};
306
+ for (const env of Object.keys(runs))
307
+ if (!environments.includes(env))
308
+ errors.push(unknownEnv(`${at}.runs.${env}`, env));
309
+ const target = isMap(svc.target) ? svc.target : null;
310
+ if (target && typeof target.env === "string")
311
+ if (!environments.includes(target.env))
312
+ errors.push(unknownEnv(`${at}.target.env`, target.env));
313
+ if (runs[LOCAL] === "remote" && !target)
314
+ errors.push({
315
+ path: `${at}.target`,
316
+ message: "runs.local is remote: say where it runs with target.env",
317
+ suggestion: "e.g. target: { env: dev, url: https://…/health }",
318
+ });
319
+ }
320
+ return errors;
321
+ }