@monster-orange/den 0.1.0 → 0.2.1

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 (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +1 -1
  3. package/bin/den +8 -0
  4. package/docs/distributions.md +283 -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 +19 -2
  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 +333 -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 +184 -55
  35. package/src/ui/cli/mcp-connect.ts +350 -0
  36. package/src/ui/cli/mcp-main.ts +13 -0
  37. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  38. package/src/ui/cli/named-target.ts +11 -0
  39. package/src/ui/cli/stack-command.ts +13 -40
  40. package/src/ui/cli/test-runner.ts +8 -1
  41. package/src/ui/mcp/api.ts +286 -0
  42. package/src/ui/mcp/connect.ts +304 -0
  43. package/src/ui/mcp/guards.ts +145 -0
  44. package/src/ui/mcp/server.ts +149 -0
  45. package/src/ui/mcp/tools.ts +644 -0
  46. package/src/ui/server/audit.ts +137 -0
  47. package/src/ui/server/changelog-parser.ts +11 -0
  48. package/src/ui/server/graph-prefs.ts +89 -0
  49. package/src/ui/server/health-row.ts +15 -0
  50. package/src/ui/server/index.ts +256 -61
  51. package/src/ui/server/lifecycle-exec.ts +188 -0
  52. package/src/ui/server/log-history.ts +4 -3
  53. package/src/ui/server/public/environments.html +4 -2
  54. package/src/ui/server/public/graph-model.js +416 -0
  55. package/src/ui/server/public/graph.css +980 -0
  56. package/src/ui/server/public/graph.js +986 -0
  57. package/src/ui/server/public/header.js +7 -5
  58. package/src/ui/server/public/local-dev.html +130 -2191
  59. package/src/ui/server/public/logs.html +11 -4
  60. package/src/ui/server/public/panel.js +790 -0
  61. package/src/ui/server/public/services.html +6 -1
  62. package/src/ui/server/readonly-routes.ts +209 -0
  63. package/src/ui/server/ui-assets.ts +73 -0
  64. package/src/ui/server/workspace-routes.ts +39 -8
@@ -847,6 +847,11 @@
847
847
  const branchBadge = s.branch
848
848
  ? `<span class="badge branch">${s.branch}</span>`
849
849
  : "";
850
+ // Which manifest layers built this card: repo or held, + override.
851
+ const layersBadge =
852
+ Array.isArray(s.manifestLayers) && s.manifestLayers.length > 0
853
+ ? `<span class="badge layers" title="Manifest layers applied">manifest: ${s.manifestLayers.join(" + ")}</span>`
854
+ : "";
850
855
  return `
851
856
  <div class="service-card${isSub ? " subservice" : ""}">
852
857
  <div class="service-card-header">
@@ -857,7 +862,7 @@
857
862
  <a class="site-link" id="site-${s.repo}-${s.service}" target="_blank" rel="noopener" hidden></a>
858
863
  </div>
859
864
  </div>
860
- <div class="service-meta">${modeBadge}${branchBadge}${unclonedBadge}</div>
865
+ <div class="service-meta">${modeBadge}${branchBadge}${layersBadge}${unclonedBadge}</div>
861
866
  <div class="cmd-row">
862
867
  ${hasCaps ? cmdButtons + logsButton : `<span style="color:var(--muted);font-size:11px">${emptyNote}</span>`}
863
868
  </div>
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Read-only status endpoints (GET only):
3
+ *
4
+ * /api/stacks what `stack ls` + `stack health` show
5
+ * ?workspace=<den workspace> ?health=0 skips docker
6
+ * /api/edge what `edge status` shows: up/down, routes, cert names
7
+ * /api/security/plan the security CLI's own dry run for ?env&mode&service:
8
+ * target discovery and the owned-host check, nothing run
9
+ * /api/activity the audit trail for one service (?address=repo/svc) or
10
+ * repo (?repo=): execs and syncs, yours and agents',
11
+ * newest first (the graph panel's Activity tab)
12
+ *
13
+ * The MCP server's `stacks`, `edge_status` and `run_tests` (its security
14
+ * pre-flight) read these. Collaborators are injectable for tests.
15
+ */
16
+ import * as http from "http";
17
+ import { readEdgeConfig } from "../../shared/edge/config.js";
18
+ import { edgeStatus, type EdgeStatus } from "../../shared/edge/status.js";
19
+ import { getWorkspaceByName } from "../../shared/environment-manager/workspaces.js";
20
+ import {
21
+ loadStackContext,
22
+ resolveStackWorkspaces,
23
+ stackStatuses,
24
+ type StackStatus,
25
+ } from "../../shared/stacks/status.js";
26
+ import { send400, sendJson } from "./http-helpers.js";
27
+ import { recentActions, type AuditEntry } from "./audit.js";
28
+
29
+ export interface SecurityPlanArgs {
30
+ env: string;
31
+ mode: string;
32
+ service?: string;
33
+ }
34
+
35
+ export interface SecurityPlanResult {
36
+ exitCode: number;
37
+ log: string[];
38
+ errors: string[];
39
+ }
40
+
41
+ export interface ReadonlyRouteDeps {
42
+ workspaceExists: (name: string) => boolean;
43
+ stacks: (opts: { workspace: string | null; health: boolean }) => {
44
+ workspace: string;
45
+ stackWorkspace: string;
46
+ stacks: StackStatus[];
47
+ };
48
+ edge: () => EdgeStatus;
49
+ actions: () => AuditEntry[];
50
+ securityPlan: (args: SecurityPlanArgs) => Promise<SecurityPlanResult>;
51
+ }
52
+
53
+ export const defaultReadonlyRouteDeps: ReadonlyRouteDeps = {
54
+ workspaceExists: (name) => !!getWorkspaceByName(name),
55
+ stacks: ({ workspace, health }) => {
56
+ const ws = resolveStackWorkspaces(workspace);
57
+ const ctx = loadStackContext(ws.denWorkspace);
58
+ return {
59
+ workspace: ws.denWorkspace,
60
+ stackWorkspace: ws.stackWorkspace,
61
+ stacks: stackStatuses(ctx, { health }),
62
+ };
63
+ },
64
+ edge: () => edgeStatus(readEdgeConfig()),
65
+ actions: recentActions,
66
+ securityPlan: async ({ env, mode, service }) => {
67
+ // The CLI's own entrypoint in --dry-run: the same discovery, mode
68
+ // ceiling and owned-host check, with no token minted and nothing run.
69
+ const { runSecurityCli, defaultEntrypointDeps } =
70
+ await import("../../pillars/security/run.js");
71
+ const log: string[] = [];
72
+ const errors: string[] = [];
73
+ const argv = [`--env=${env}`, `--mode=${mode}`, "--dry-run"];
74
+ if (service) argv.push(`--service=${service}`);
75
+ const exitCode = await runSecurityCli(argv, {
76
+ ...defaultEntrypointDeps(),
77
+ log: (m) => log.push(m),
78
+ logError: (m) => errors.push(m),
79
+ });
80
+ return { exitCode, log, errors };
81
+ },
82
+ };
83
+
84
+ const SAFE_NAME = /^[A-Za-z0-9._/-]{1,128}$/;
85
+
86
+ /**
87
+ * The entries about one service or repo. An exec's detail starts with the
88
+ * address ("shop/api up (local) [id]"); a sync names its repo ("· repo=shop").
89
+ */
90
+ export function activityFor(
91
+ entries: AuditEntry[],
92
+ target: { address?: string; repo?: string },
93
+ ): AuditEntry[] {
94
+ // A repo-level entry ("core down (local)") is an attempt on the repo
95
+ // without naming a service, as agents usually do: it shows for the repo
96
+ // and for each of its services.
97
+ const repoLevel = (e: AuditEntry, repo: string): boolean =>
98
+ e.verb === "exec" && e.detail.startsWith(repo + " ");
99
+ const matches = (e: AuditEntry): boolean => {
100
+ if (target.address)
101
+ return (
102
+ (e.verb === "exec" && e.detail.startsWith(target.address + " ")) ||
103
+ repoLevel(e, target.address.split("/")[0])
104
+ );
105
+ const repo = target.repo ?? "";
106
+ if (e.verb === "exec")
107
+ return e.detail.startsWith(repo + "/") || repoLevel(e, repo);
108
+ if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
109
+ return false;
110
+ };
111
+ return entries.filter(matches).reverse();
112
+ }
113
+ const MODES = ["baseline", "api", "full", "all"];
114
+
115
+ /** Handles the routes above; false when `pathname` is not one of them. */
116
+ export async function handleReadonlyRoutes(
117
+ req: http.IncomingMessage,
118
+ res: http.ServerResponse,
119
+ pathname: string,
120
+ url: URL,
121
+ deps: ReadonlyRouteDeps = defaultReadonlyRouteDeps,
122
+ ): Promise<boolean> {
123
+ if (
124
+ pathname !== "/api/stacks" &&
125
+ pathname !== "/api/edge" &&
126
+ pathname !== "/api/activity" &&
127
+ pathname !== "/api/security/plan"
128
+ )
129
+ return false;
130
+ if (req.method !== "GET") {
131
+ res.writeHead(405, { Allow: "GET" });
132
+ res.end("read-only");
133
+ return true;
134
+ }
135
+
136
+ if (pathname === "/api/stacks") {
137
+ const workspace = url.searchParams.get("workspace") || null;
138
+ if (workspace && !deps.workspaceExists(workspace)) {
139
+ send400(res, `unknown workspace "${workspace}"`);
140
+ return true;
141
+ }
142
+ try {
143
+ sendJson(
144
+ res,
145
+ deps.stacks({
146
+ workspace,
147
+ health: url.searchParams.get("health") !== "0",
148
+ }),
149
+ );
150
+ } catch (e) {
151
+ sendJson(res, { error: (e as Error).message }, 500);
152
+ }
153
+ return true;
154
+ }
155
+
156
+ if (pathname === "/api/edge") {
157
+ try {
158
+ sendJson(res, { configured: true, ...deps.edge() });
159
+ } catch (e) {
160
+ // No edge configured is a state, not a server error.
161
+ sendJson(res, { configured: false, error: (e as Error).message });
162
+ }
163
+ return true;
164
+ }
165
+
166
+ if (pathname === "/api/activity") {
167
+ const address = url.searchParams.get("address") ?? undefined;
168
+ const repo = url.searchParams.get("repo") ?? undefined;
169
+ const name = address ?? repo;
170
+ if (!name || !SAFE_NAME.test(name)) {
171
+ send400(res, "address or repo is required");
172
+ return true;
173
+ }
174
+ sendJson(res, { entries: activityFor(deps.actions(), { address, repo }) });
175
+ return true;
176
+ }
177
+
178
+ // /api/security/plan
179
+ const env = url.searchParams.get("env") ?? "";
180
+ const mode = url.searchParams.get("mode") ?? "baseline";
181
+ const service = url.searchParams.get("service") ?? undefined;
182
+ if (!SAFE_NAME.test(env)) {
183
+ send400(res, "env is required");
184
+ return true;
185
+ }
186
+ if (!MODES.includes(mode)) {
187
+ send400(res, `mode must be one of ${MODES.join(", ")}`);
188
+ return true;
189
+ }
190
+ if (service !== undefined && !SAFE_NAME.test(service)) {
191
+ send400(res, "invalid service");
192
+ return true;
193
+ }
194
+ try {
195
+ const plan = await deps.securityPlan({
196
+ env,
197
+ mode,
198
+ ...(service ? { service } : {}),
199
+ });
200
+ sendJson(res, { ok: plan.exitCode === 0, ...plan });
201
+ } catch (e) {
202
+ sendJson(
203
+ res,
204
+ { ok: false, exitCode: 1, log: [], errors: [(e as Error).message] },
205
+ 500,
206
+ );
207
+ }
208
+ return true;
209
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Static assets the graph needs beyond the pages themselves:
3
+ * - the graph's own modules in public/ (UI_MODULES), served like header.js;
4
+ * - browser libraries the UI loads, served by Den's own server from
5
+ * node_modules (never a CDN), so the UI works offline. Each is a pinned
6
+ * dependency in package.json. npm hoists Den's dependencies into a
7
+ * distribution's node_modules, so the lookup walks up from the engine root,
8
+ * like engineBin does.
9
+ */
10
+ import * as fs from "fs";
11
+ import * as http from "http";
12
+ import * as path from "path";
13
+ import { getEngineRoot } from "../../shared/paths.js";
14
+ import { serveFile } from "./http-helpers.js";
15
+
16
+ /** The graph's modules in public/, each served at /<name>. */
17
+ export const UI_MODULES: readonly string[] = [
18
+ "/graph.js",
19
+ "/graph-model.js",
20
+ "/panel.js",
21
+ "/graph.css",
22
+ ];
23
+
24
+ /** URL path → file inside node_modules. */
25
+ export const VENDOR_ASSETS: Readonly<Record<string, string>> = {
26
+ "/vendor/elk.bundled.js": "elkjs/lib/elk.bundled.js",
27
+ };
28
+
29
+ /** The installed file for a vendored URL path, or null. */
30
+ export function vendorAssetFile(
31
+ pathname: string,
32
+ engineRoot: string = getEngineRoot(),
33
+ ): string | null {
34
+ const rel = Object.hasOwn(VENDOR_ASSETS, pathname)
35
+ ? VENDOR_ASSETS[pathname]
36
+ : undefined;
37
+ if (!rel) return null;
38
+ let dir = path.resolve(engineRoot);
39
+ for (;;) {
40
+ const candidate = path.join(dir, "node_modules", rel);
41
+ if (fs.existsSync(candidate)) return candidate;
42
+ const parent = path.dirname(dir);
43
+ if (parent === dir) return null;
44
+ dir = parent;
45
+ }
46
+ }
47
+
48
+ /** Serves a UI module or vendored asset; false when `pathname` is neither. */
49
+ export function serveUiAsset(
50
+ res: http.ServerResponse,
51
+ pathname: string,
52
+ publicDir: string,
53
+ ): boolean {
54
+ if (UI_MODULES.includes(pathname)) {
55
+ serveFile(res, path.join(publicDir, pathname.slice(1)));
56
+ return true;
57
+ }
58
+ if (!pathname.startsWith("/vendor/")) return false;
59
+ const file = vendorAssetFile(pathname);
60
+ if (!file) {
61
+ res.writeHead(404, { "Content-Type": "text/plain" });
62
+ res.end("Not found");
63
+ return true;
64
+ }
65
+ // Pinned versions: the bytes only change with Den itself. Not run through
66
+ // the product-token fill the UI's own scripts get.
67
+ res.writeHead(200, {
68
+ "Content-Type": "application/javascript",
69
+ "Cache-Control": "public, max-age=86400",
70
+ });
71
+ res.end(fs.readFileSync(file));
72
+ return true;
73
+ }
@@ -69,6 +69,10 @@ import {
69
69
  orderForUp,
70
70
  } from "../../shared/environment-manager/derivation/derived-up.js";
71
71
  import { readManifest } from "../../shared/manifest/read-manifest.js";
72
+ import {
73
+ placementRefusal,
74
+ servicePlacement,
75
+ } from "../../shared/manifest/placement.js";
72
76
  import {
73
77
  checkRepoSecrets,
74
78
  describeSecretsCheck,
@@ -85,6 +89,11 @@ import {
85
89
  import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
86
90
  import { getDistRoot } from "../../shared/paths.js";
87
91
  import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
92
+ import {
93
+ devManagedPaths,
94
+ selectSyncTargets,
95
+ } from "../../shared/environment-manager/sync-targets.js";
96
+ import { actorFromHeaders, recordAction } from "./audit.js";
88
97
  import {
89
98
  GIT_REMOTE_TIMEOUT_MS,
90
99
  DETACH_WINDOW_MS,
@@ -1472,6 +1481,22 @@ export async function handleWorkspaceRoutes(
1472
1481
  return;
1473
1482
  }
1474
1483
  }
1484
+ // R13: a remote service is part of the app but never run from here.
1485
+ const remote = placementRefusal(
1486
+ address,
1487
+ servicePlacement(
1488
+ readManifest(resolved.repoPath),
1489
+ resolved.subsystem,
1490
+ "local",
1491
+ ),
1492
+ () => false,
1493
+ cmd,
1494
+ );
1495
+ if (remote) {
1496
+ emit({ type: "line", text: ` ${remote}` });
1497
+ runNext(i + 1);
1498
+ return;
1499
+ }
1475
1500
  const svcCmds = localCommandsFor(resolved, ws.name);
1476
1501
  const hasCmd =
1477
1502
  cmd === "down" ||
@@ -1906,14 +1931,20 @@ export async function handleWorkspaceRoutes(
1906
1931
  // Optional ?repo=<name> syncs a single system (the card's ⟳ sync action);
1907
1932
  // omitted syncs the whole workspace (the master ⟳ Sync button).
1908
1933
  const onlyRepo = url.searchParams.get("repo");
1909
- // Only sync managed services. Deduplicate by repoPath.
1910
- const seen = new Set<string>();
1911
- const targets = services.filter((s) => {
1912
- if (s.mode !== "managed") return false;
1913
- if (onlyRepo && s.repoName !== onlyRepo) return false;
1914
- if (seen.has(s.repoPath)) return false;
1915
- seen.add(s.repoPath);
1916
- return true;
1934
+ // Only managed clones, deduplicated by path; never a dev-managed checkout
1935
+ // (see sync-targets.ts), and a refusal is said out loud.
1936
+ const { targets, refused } = selectSyncTargets(services, {
1937
+ onlyRepo,
1938
+ devManaged: devManagedPaths(readWorkspacesFile()),
1939
+ });
1940
+ for (const r of refused)
1941
+ emit({
1942
+ type: "line",
1943
+ text: ` ${r.repoName}: not synced — ${r.reason}`,
1944
+ });
1945
+ recordAction(actorFromHeaders(req.headers), {
1946
+ verb: "sync",
1947
+ detail: `${ws.name}${onlyRepo ? ` · repo=${onlyRepo}` : ""} · ${targets.length} managed repo(s)`,
1917
1948
  });
1918
1949
 
1919
1950
  if (targets.length === 0) {