@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.
- package/CHANGELOG.md +204 -0
- package/README.md +1 -1
- package/bin/den +8 -0
- package/docs/distributions.md +283 -12
- package/package.json +4 -2
- package/src/pillars/journeys/sandbox.ts +2 -5
- package/src/pillars/security/discover.ts +17 -1
- package/src/pillars/security/run.ts +31 -3
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/den-config.ts +122 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/environment-manager/health.ts +54 -8
- package/src/shared/environment-manager/load-applications.ts +31 -1
- package/src/shared/environment-manager/log-tail.ts +5 -4
- package/src/shared/environment-manager/opctl.ts +2 -1
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-runner.ts +19 -2
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +25 -1
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/placement.ts +333 -0
- package/src/shared/manifest/read-manifest.ts +156 -38
- package/src/shared/manifest/schema.ts +14 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +66 -6
- package/src/shared/paths.ts +12 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/stacks/manifest-commands.ts +30 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/types/index.ts +23 -0
- package/src/ui/cli/applications-command.ts +34 -2
- package/src/ui/cli/edge-command.ts +9 -19
- package/src/ui/cli/graph-command.ts +50 -4
- package/src/ui/cli/index.ts +184 -55
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/stack-command.ts +13 -40
- package/src/ui/cli/test-runner.ts +8 -1
- package/src/ui/mcp/api.ts +286 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +145 -0
- package/src/ui/mcp/server.ts +149 -0
- package/src/ui/mcp/tools.ts +644 -0
- package/src/ui/server/audit.ts +137 -0
- package/src/ui/server/changelog-parser.ts +11 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/index.ts +256 -61
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/log-history.ts +4 -3
- package/src/ui/server/public/environments.html +4 -2
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +980 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +7 -5
- package/src/ui/server/public/local-dev.html +130 -2191
- package/src/ui/server/public/logs.html +11 -4
- package/src/ui/server/public/panel.js +790 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +209 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +39 -8
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action audit trail: every service exec, test run and sync, with who
|
|
3
|
+
* asked (a person in the UI, or an agent through `<cli> mcp`). Each action
|
|
4
|
+
* becomes a lifecycle line in the server log, the stream the /logs page shows
|
|
5
|
+
* as the web-server log, so agent actions sit beside the UI's own; the
|
|
6
|
+
* structured entries are kept in a small in-memory ring.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
actorLabel,
|
|
10
|
+
type Actor,
|
|
11
|
+
type AgentActor,
|
|
12
|
+
} from "../../shared/agent-actor.js";
|
|
13
|
+
|
|
14
|
+
export { actorFromHeaders } from "../../shared/agent-actor.js";
|
|
15
|
+
|
|
16
|
+
export interface AuditEntry {
|
|
17
|
+
ts: string;
|
|
18
|
+
actor: "agent" | "user";
|
|
19
|
+
client?: string;
|
|
20
|
+
clientVersion?: string;
|
|
21
|
+
mcpPid?: number | null;
|
|
22
|
+
session?: string;
|
|
23
|
+
/** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
|
|
24
|
+
verb: string;
|
|
25
|
+
detail: string;
|
|
26
|
+
/** Set when the action never ran: a guard or the server refused it. */
|
|
27
|
+
outcome?: "refused";
|
|
28
|
+
/** Why it was refused (first line). */
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Sink = (line: string, actor: AgentActor | null) => void;
|
|
33
|
+
|
|
34
|
+
const RING_MAX = 200;
|
|
35
|
+
const ring: AuditEntry[] = [];
|
|
36
|
+
let sink: Sink | null = null;
|
|
37
|
+
|
|
38
|
+
/** Where audit lines go (the server log); null to disconnect. */
|
|
39
|
+
export function setAuditSink(fn: Sink | null): void {
|
|
40
|
+
sink = fn;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** " · by agent …" for an agent, "" for a person. */
|
|
44
|
+
export function actorSuffix(actor: Actor): string {
|
|
45
|
+
return actor ? ` · by ${actorLabel(actor)}` : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordAction(
|
|
49
|
+
actor: Actor,
|
|
50
|
+
action: {
|
|
51
|
+
verb: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
outcome?: "refused";
|
|
54
|
+
reason?: string;
|
|
55
|
+
},
|
|
56
|
+
): AuditEntry {
|
|
57
|
+
const entry: AuditEntry = {
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
actor: actor ? "agent" : "user",
|
|
60
|
+
...(actor
|
|
61
|
+
? {
|
|
62
|
+
client: actor.client,
|
|
63
|
+
clientVersion: actor.clientVersion,
|
|
64
|
+
mcpPid: actor.mcpPid,
|
|
65
|
+
session: actor.session,
|
|
66
|
+
}
|
|
67
|
+
: {}),
|
|
68
|
+
verb: action.verb,
|
|
69
|
+
detail: action.detail,
|
|
70
|
+
...(action.outcome ? { outcome: action.outcome } : {}),
|
|
71
|
+
...(action.reason ? { reason: clean(action.reason, 300) } : {}),
|
|
72
|
+
};
|
|
73
|
+
ring.push(entry);
|
|
74
|
+
if (ring.length > RING_MAX) ring.shift();
|
|
75
|
+
const mark = entry.outcome === "refused" ? "✗" : "▶";
|
|
76
|
+
const why = entry.reason ? ` — refused: ${entry.reason}` : "";
|
|
77
|
+
sink?.(
|
|
78
|
+
`${mark} ${action.verb} ${action.detail}${why}${actorSuffix(actor)}`,
|
|
79
|
+
actor,
|
|
80
|
+
);
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One line, printable characters only, at most `max` long. */
|
|
85
|
+
function clean(v: unknown, max: number): string {
|
|
86
|
+
const line = String(v ?? "").split(/[\r\n]/)[0];
|
|
87
|
+
return [...line]
|
|
88
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
|
|
89
|
+
.join("")
|
|
90
|
+
.slice(0, max);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
|
|
94
|
+
const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An MCP guard refused an agent's call before it reached an action route.
|
|
98
|
+
* The agent's own report, so every field is sanitised: a lifecycle tool with
|
|
99
|
+
* a valid repo becomes an "exec" entry for that service (what the panel's
|
|
100
|
+
* activity list shows); anything else is recorded under the tool's name.
|
|
101
|
+
*/
|
|
102
|
+
export function recordAgentRefusal(
|
|
103
|
+
actor: Actor,
|
|
104
|
+
report: {
|
|
105
|
+
tool?: unknown;
|
|
106
|
+
repo?: unknown;
|
|
107
|
+
service?: unknown;
|
|
108
|
+
reason?: unknown;
|
|
109
|
+
},
|
|
110
|
+
): AuditEntry {
|
|
111
|
+
const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
|
|
112
|
+
const repo = typeof report.repo === "string" ? report.repo : "";
|
|
113
|
+
const service = typeof report.service === "string" ? report.service : "";
|
|
114
|
+
const lifecycle = LIFECYCLE_TOOL.exec(tool);
|
|
115
|
+
const reason = clean(report.reason, 300) || "refused";
|
|
116
|
+
if (lifecycle && SAFE_PART.test(repo)) {
|
|
117
|
+
const address =
|
|
118
|
+
service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
|
|
119
|
+
return recordAction(actor, {
|
|
120
|
+
verb: "exec",
|
|
121
|
+
detail: `${address} ${lifecycle[1]} (local)`,
|
|
122
|
+
outcome: "refused",
|
|
123
|
+
reason,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return recordAction(actor, {
|
|
127
|
+
verb: tool,
|
|
128
|
+
detail: "",
|
|
129
|
+
outcome: "refused",
|
|
130
|
+
reason,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The most recent actions, oldest first. */
|
|
135
|
+
export function recentActions(): AuditEntry[] {
|
|
136
|
+
return ring.slice();
|
|
137
|
+
}
|
|
@@ -84,6 +84,17 @@ export function parseChangelog(raw: string): ChangelogEntry[] {
|
|
|
84
84
|
line.replace(/^ {2}- /, "").trim(),
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
|
+
} else if (currentSection && /^ {2,}\S/.test(line)) {
|
|
88
|
+
// A wrapped continuation line: it belongs to the bullet (or sub-bullet)
|
|
89
|
+
// above it. Dropping it cut every multi-line bullet mid-sentence.
|
|
90
|
+
const bullets = current.sections[currentSection];
|
|
91
|
+
const last = bullets[bullets.length - 1];
|
|
92
|
+
if (last) {
|
|
93
|
+
const text = line.trim();
|
|
94
|
+
if (/^ {4,}/.test(line) && last.sub.length > 0)
|
|
95
|
+
last.sub[last.sub.length - 1] += " " + text;
|
|
96
|
+
else last.text += " " + text;
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
100
|
if (current) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph preferences: how the Local Dev graph is laid out for this engineer.
|
|
3
|
+
* Stored in the user config as `graph:` and served by /api/settings, so the
|
|
4
|
+
* layout is remembered between sessions (the page mirrors it to localStorage
|
|
5
|
+
* so the first paint does not wait on the server).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface GraphPrefs {
|
|
9
|
+
/** Layout direction: top-down or left-right. */
|
|
10
|
+
direction: "down" | "right";
|
|
11
|
+
/** The outer frames: by org or by application. */
|
|
12
|
+
group: "org" | "app";
|
|
13
|
+
/** Card size: detailed (236×104) or compact (176×52). */
|
|
14
|
+
density: "detailed" | "compact";
|
|
15
|
+
/** Details panel width in px. */
|
|
16
|
+
panelWidth: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const GRAPH_DEFAULTS: GraphPrefs = {
|
|
20
|
+
direction: "down",
|
|
21
|
+
group: "org",
|
|
22
|
+
density: "detailed",
|
|
23
|
+
panelWidth: 440,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CHOICES = {
|
|
27
|
+
direction: ["down", "right"],
|
|
28
|
+
group: ["org", "app"],
|
|
29
|
+
density: ["detailed", "compact"],
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const PANEL_MIN = 360;
|
|
33
|
+
const PANEL_MAX = 760;
|
|
34
|
+
|
|
35
|
+
type Check = { ok: true; value: unknown } | { ok: false; error: string };
|
|
36
|
+
|
|
37
|
+
function check(key: keyof GraphPrefs, v: unknown): Check {
|
|
38
|
+
if (key === "panelWidth") {
|
|
39
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
40
|
+
return { ok: false, error: panelError() };
|
|
41
|
+
const w = Math.round(v);
|
|
42
|
+
if (w < PANEL_MIN || w > PANEL_MAX)
|
|
43
|
+
return { ok: false, error: panelError() };
|
|
44
|
+
return { ok: true, value: w };
|
|
45
|
+
}
|
|
46
|
+
const allowed: readonly string[] = CHOICES[key];
|
|
47
|
+
if (typeof v !== "string" || !allowed.includes(v))
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `graph.${key} must be one of ${allowed.map((a) => `"${a}"`).join(", ")}`,
|
|
51
|
+
};
|
|
52
|
+
return { ok: true, value: v };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function panelError(): string {
|
|
56
|
+
return `graph.panelWidth must be a number from ${PANEL_MIN} to ${PANEL_MAX}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
60
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
61
|
+
|
|
62
|
+
/** The stored block, normalised: a missing or hand-broken field is its default. */
|
|
63
|
+
export function graphPrefs(stored: unknown): GraphPrefs {
|
|
64
|
+
const out: GraphPrefs = { ...GRAPH_DEFAULTS };
|
|
65
|
+
if (!isObject(stored)) return out;
|
|
66
|
+
for (const key of Object.keys(GRAPH_DEFAULTS) as (keyof GraphPrefs)[]) {
|
|
67
|
+
if (!(key in stored)) continue;
|
|
68
|
+
const c = check(key, stored[key]);
|
|
69
|
+
if (c.ok) (out as unknown as Record<string, unknown>)[key] = c.value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Merge a POSTed (partial) block over the current prefs; any bad field fails it. */
|
|
75
|
+
export function mergeGraphPrefs(
|
|
76
|
+
current: GraphPrefs,
|
|
77
|
+
patch: unknown,
|
|
78
|
+
): { ok: true; value: GraphPrefs } | { ok: false; error: string } {
|
|
79
|
+
if (!isObject(patch)) return { ok: false, error: "graph must be an object" };
|
|
80
|
+
const out: GraphPrefs = { ...current };
|
|
81
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
82
|
+
if (!(key in GRAPH_DEFAULTS))
|
|
83
|
+
return { ok: false, error: `unknown graph setting "${key}"` };
|
|
84
|
+
const c = check(key as keyof GraphPrefs, v);
|
|
85
|
+
if (!c.ok) return c;
|
|
86
|
+
(out as unknown as Record<string, unknown>)[key] = c.value;
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, value: out };
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fields of a /api/health row that say what was checked. `url` is only
|
|
3
|
+
* ever a URL (or null): a shell check's command (a compose stack's
|
|
4
|
+
* `<cli> stack health …`, or any declared shell check) goes in `check`.
|
|
5
|
+
*/
|
|
6
|
+
export type HealthCheckType = "shell" | "http" | "none";
|
|
7
|
+
|
|
8
|
+
export function healthCheckFields(
|
|
9
|
+
checkType: HealthCheckType,
|
|
10
|
+
target: string,
|
|
11
|
+
): { url: string | null; check?: string } {
|
|
12
|
+
if (checkType === "shell") return { url: null, check: target };
|
|
13
|
+
if (checkType === "http") return { url: target };
|
|
14
|
+
return { url: null };
|
|
15
|
+
}
|