@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction, in two forms:
|
|
3
|
+
*
|
|
4
|
+
* - Known values (`redactValues`, `containsValue`): a run holds the live
|
|
5
|
+
* secret it minted (a bearer token, an agent key) and strips or refuses
|
|
6
|
+
* that exact string. The journeys and security pillars use these.
|
|
7
|
+
* - Secret-shaped values (`redactSecrets`): text from somewhere we don't
|
|
8
|
+
* control (service logs, command output) is scrubbed by pattern before it
|
|
9
|
+
* leaves Den, e.g. through the MCP server. Patterns err on the side of
|
|
10
|
+
* redacting: a hidden harmless value costs less than a leaked key.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const REDACTED = "[REDACTED]";
|
|
14
|
+
|
|
15
|
+
/** Replace every occurrence of each non-empty value in `text`. */
|
|
16
|
+
export function redactValues(
|
|
17
|
+
text: string,
|
|
18
|
+
values: readonly (string | undefined)[],
|
|
19
|
+
): string {
|
|
20
|
+
let out = text;
|
|
21
|
+
for (const v of values) if (v) out = out.split(v).join(REDACTED);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether `text` contains any non-empty value. */
|
|
26
|
+
export function containsValue(
|
|
27
|
+
text: string,
|
|
28
|
+
values: readonly (string | undefined)[],
|
|
29
|
+
): boolean {
|
|
30
|
+
return values.some((v) => !!v && text.includes(v));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Key names whose values are secrets (matched anywhere in the key). */
|
|
34
|
+
const SECRET_KEY =
|
|
35
|
+
"[\\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credentials?|cookie)[\\w.-]*";
|
|
36
|
+
|
|
37
|
+
interface Pattern {
|
|
38
|
+
name: string;
|
|
39
|
+
re: RegExp;
|
|
40
|
+
replace: string | ((...m: string[]) => string);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Order matters: whole blocks first, then structured forms (URLs, auth
|
|
45
|
+
* headers), then bare token shapes, then generic key=value.
|
|
46
|
+
*/
|
|
47
|
+
export const SECRET_PATTERNS: readonly Pattern[] = [
|
|
48
|
+
{
|
|
49
|
+
name: "private-key block",
|
|
50
|
+
re: /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----[\s\S]*?-----END \1PRIVATE KEY-----/g,
|
|
51
|
+
replace: (_m, kind) =>
|
|
52
|
+
`-----BEGIN ${kind}PRIVATE KEY-----${REDACTED}-----END ${kind}PRIVATE KEY-----`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
// A block cut off by a tail window: redact to the end of the text.
|
|
56
|
+
name: "private-key block (unterminated)",
|
|
57
|
+
re: /-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----(?![\s\S]*-----END)(?!\[REDACTED\])[\s\S]*$/g,
|
|
58
|
+
replace: (_m, kind) => `-----BEGIN ${kind}PRIVATE KEY-----${REDACTED}`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "URL credentials",
|
|
62
|
+
re: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/]+):([^\s@/]+)@/gi,
|
|
63
|
+
replace: (_m, scheme, user, pass) =>
|
|
64
|
+
pass === REDACTED ? _m : `${scheme}${user}:${REDACTED}@`,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "bearer token",
|
|
68
|
+
re: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
69
|
+
replace: (_m, scheme) => `${scheme} ${REDACTED}`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "JWT",
|
|
73
|
+
re: /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g,
|
|
74
|
+
replace: REDACTED,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "AWS access key id",
|
|
78
|
+
re: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[A-Z0-9]{16}\b/g,
|
|
79
|
+
replace: REDACTED,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "well-known token prefix",
|
|
83
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|sk-[A-Za-z0-9_-]{20,})/g,
|
|
84
|
+
replace: REDACTED,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "key=value secret",
|
|
88
|
+
re: new RegExp(
|
|
89
|
+
`\\b(${SECRET_KEY})(["']?\\s*[:=]\\s*)(\\[REDACTED\\]|"[^"]*"|'[^']*'|[^\\s,;&'"}\\]]+)`,
|
|
90
|
+
"gi",
|
|
91
|
+
),
|
|
92
|
+
replace: (_m, key, sep, value) => {
|
|
93
|
+
const q = value[0] === '"' || value[0] === "'" ? value[0] : "";
|
|
94
|
+
return `${key}${sep}${q}${REDACTED}${q}`;
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Redact every secret-shaped value in `text` (see SECRET_PATTERNS). */
|
|
100
|
+
export function redactSecrets(text: string): string {
|
|
101
|
+
let out = text;
|
|
102
|
+
for (const p of SECRET_PATTERNS) {
|
|
103
|
+
const r = p.replace;
|
|
104
|
+
out = typeof r === "string" ? out.replace(p.re, r) : out.replace(p.re, r);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -8,6 +8,7 @@ import { getDistRoot } from "../paths.js";
|
|
|
8
8
|
import { readManifest } from "../manifest/read-manifest.js";
|
|
9
9
|
import type { RawManifest } from "../types/index.js";
|
|
10
10
|
import { manifestStackName } from "./config.js";
|
|
11
|
+
import { containerName, parseTarget, projectName } from "./render.js";
|
|
11
12
|
|
|
12
13
|
import { product } from "../product.js";
|
|
13
14
|
type CommandDef = string | { cmd: string; detachable?: boolean };
|
|
@@ -63,6 +64,35 @@ export function composeHealthCmd(
|
|
|
63
64
|
: `${base} --service ${JSON.stringify(subsystem)}`;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The containers a compose-stack service runs as, best match first ([] when
|
|
69
|
+
* the repo isn't a compose stack). Any other manifest service names a compose
|
|
70
|
+
* service; the primary one also falls back to its routes' target services.
|
|
71
|
+
* `stackWorkspace` is the Compose project suffix (see resolveStackWorkspaces).
|
|
72
|
+
*/
|
|
73
|
+
export function composeServiceContainers(
|
|
74
|
+
manifest: RawManifest,
|
|
75
|
+
subsystem: string,
|
|
76
|
+
repoName: string,
|
|
77
|
+
stackWorkspace: string,
|
|
78
|
+
): string[] {
|
|
79
|
+
if (!manifest.compose) return [];
|
|
80
|
+
const project = projectName(
|
|
81
|
+
manifestStackName(manifest, repoName),
|
|
82
|
+
stackWorkspace,
|
|
83
|
+
);
|
|
84
|
+
const services = [subsystem];
|
|
85
|
+
if (isPrimary(manifest, subsystem))
|
|
86
|
+
for (const target of Object.values(manifest.compose.routes ?? {})) {
|
|
87
|
+
try {
|
|
88
|
+
services.push(parseTarget(target).service);
|
|
89
|
+
} catch {
|
|
90
|
+
/* a malformed route is reported by the stack commands */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...new Set(services)].map((svc) => containerName(project, svc));
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
/** Declared local commands layered over the synthesized compose ones. */
|
|
67
97
|
export function effectiveLocalCommands(
|
|
68
98
|
manifest: RawManifest,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only stack status: what `stack ls` and `stack health` show, as data.
|
|
3
|
+
* The CLI prints it; the web server serves it at /api/stacks (and through it
|
|
4
|
+
* the MCP `stacks` tool).
|
|
5
|
+
*/
|
|
6
|
+
import { readEdgeConfig } from "../edge/config.js";
|
|
7
|
+
import { routeUrl } from "../edge/render.js";
|
|
8
|
+
import {
|
|
9
|
+
getFocusedWorkspaceName,
|
|
10
|
+
getWorkspaceByName,
|
|
11
|
+
} from "../environment-manager/workspaces.js";
|
|
12
|
+
import { readStacks } from "./config.js";
|
|
13
|
+
import { projectName, stackWorkspace } from "./render.js";
|
|
14
|
+
import { stackHealth, type StackContext } from "./runtime.js";
|
|
15
|
+
|
|
16
|
+
export interface StackRoute {
|
|
17
|
+
name: string;
|
|
18
|
+
url: string;
|
|
19
|
+
/** `<service>:<container port>`. */
|
|
20
|
+
target: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StackStatus {
|
|
24
|
+
name: string;
|
|
25
|
+
repo: string;
|
|
26
|
+
dir: string | null;
|
|
27
|
+
/** The Compose project: `<stack>-<workspace>`. */
|
|
28
|
+
project: string;
|
|
29
|
+
routes: StackRoute[];
|
|
30
|
+
uses: string[];
|
|
31
|
+
/** Container health (only when asked for: it runs `docker ps`). */
|
|
32
|
+
health?: { ok: boolean; detail: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The den workspace stacks read their manifests from, and the Compose
|
|
39
|
+
* project suffix they run under. `denWorkspace` defaults to the CLI's
|
|
40
|
+
* -w override, then the focused workspace, then `default`.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveStackWorkspaces(
|
|
43
|
+
denWorkspace?: string | null,
|
|
44
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
): { denWorkspace: string; stackWorkspace: string } {
|
|
46
|
+
const fkWs =
|
|
47
|
+
denWorkspace ||
|
|
48
|
+
env.DEN_WORKSPACE_OVERRIDE ||
|
|
49
|
+
getFocusedWorkspaceName() ||
|
|
50
|
+
"default";
|
|
51
|
+
const ws = env.DEN_STACK_WORKSPACE || stackWorkspace(fkWs);
|
|
52
|
+
if (!WS_RE.test(ws))
|
|
53
|
+
throw new Error(
|
|
54
|
+
`workspace "${ws}" must be lowercase letters, digits and dashes`,
|
|
55
|
+
);
|
|
56
|
+
return { denWorkspace: fkWs, stackWorkspace: ws };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The edge, the known stacks and the project suffix for one workspace. */
|
|
60
|
+
export function loadStackContext(denWorkspace?: string | null): StackContext {
|
|
61
|
+
const { denWorkspace: fkWs, stackWorkspace: ws } =
|
|
62
|
+
resolveStackWorkspaces(denWorkspace);
|
|
63
|
+
const edge = readEdgeConfig();
|
|
64
|
+
// Repo manifests come from that den workspace's checkouts.
|
|
65
|
+
const stacks = readStacks(undefined, {
|
|
66
|
+
workspace: getWorkspaceByName(fkWs) ? fkWs : undefined,
|
|
67
|
+
});
|
|
68
|
+
return { edge, stacks, workspace: ws };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Each stack's name, project, routes and (optionally) container health. */
|
|
72
|
+
export function stackStatuses(
|
|
73
|
+
ctx: StackContext,
|
|
74
|
+
opts: { health?: boolean; healthOf?: typeof stackHealth } = {},
|
|
75
|
+
): StackStatus[] {
|
|
76
|
+
const healthOf = opts.healthOf ?? stackHealth;
|
|
77
|
+
return Object.values(ctx.stacks).map((s) => {
|
|
78
|
+
const status: StackStatus = {
|
|
79
|
+
name: s.name,
|
|
80
|
+
repo: s.repo,
|
|
81
|
+
dir: s.dir ?? null,
|
|
82
|
+
project: projectName(s.name, ctx.workspace),
|
|
83
|
+
routes: Object.entries(s.routes).map(([route, target]) => ({
|
|
84
|
+
name: route,
|
|
85
|
+
url: routeUrl(`${route}.${s.name}`, ctx.edge),
|
|
86
|
+
target,
|
|
87
|
+
})),
|
|
88
|
+
uses: [...s.uses],
|
|
89
|
+
};
|
|
90
|
+
if (opts.health) {
|
|
91
|
+
try {
|
|
92
|
+
status.health = healthOf(ctx, s);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
status.health = { ok: false, detail: (e as Error).message };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return status;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -164,6 +164,8 @@ export interface ValidationError {
|
|
|
164
164
|
message: string;
|
|
165
165
|
/** Optional remediation hint, e.g. "Did you mean X?" */
|
|
166
166
|
suggestion?: string;
|
|
167
|
+
/** The manifest layer file the error is in, when validating by layer. */
|
|
168
|
+
file?: string;
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
export interface ValidationResult {
|
|
@@ -266,6 +268,27 @@ export interface ServiceManagementConfig {
|
|
|
266
268
|
depends_on?: DependsOnEntry[];
|
|
267
269
|
/** OWASP ZAP options: `openapi` per env turns on the API scan; `skip` leaves it out. */
|
|
268
270
|
security?: { openapi?: Partial<Record<Environment, string>>; skip?: boolean };
|
|
271
|
+
/**
|
|
272
|
+
* Where the service runs, per env (R13): `local` (on this machine) or
|
|
273
|
+
* `remote` (deployed elsewhere; Den never starts, stops, scans or execs
|
|
274
|
+
* it). Unset envs fall back to the defaults in manifest/placement.ts.
|
|
275
|
+
*/
|
|
276
|
+
runs?: Partial<Record<Environment, ServiceRunsValue>>;
|
|
277
|
+
/** Where a remote service runs: the env, an account label, a URL to probe. */
|
|
278
|
+
target?: ServiceTargetConfig;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type ServiceRunsValue = "local" | "remote";
|
|
282
|
+
|
|
283
|
+
export interface ServiceTargetConfig {
|
|
284
|
+
/** One of the org's environments. */
|
|
285
|
+
env: string;
|
|
286
|
+
/** A label for where it is deployed (e.g. a cloud account). */
|
|
287
|
+
account?: string;
|
|
288
|
+
/** Probed for health when target.env declares no health check. */
|
|
289
|
+
url?: string;
|
|
290
|
+
/** Status codes that count as up when probing `url` (default any 2xx). */
|
|
291
|
+
expect?: number | number[];
|
|
269
292
|
}
|
|
270
293
|
|
|
271
294
|
export interface ServiceLink {
|
|
@@ -14,6 +14,31 @@ import type {
|
|
|
14
14
|
} from "../../shared/applications.js";
|
|
15
15
|
|
|
16
16
|
import { product } from "../../shared/product.js";
|
|
17
|
+
import type {
|
|
18
|
+
Placement,
|
|
19
|
+
RollupPlacement,
|
|
20
|
+
} from "../../shared/manifest/placement.js";
|
|
21
|
+
|
|
22
|
+
/** An application's placement line: "⇄ hybrid: <summary>", "☁ remote", or "". */
|
|
23
|
+
export function describeAppPlacement(a: {
|
|
24
|
+
placement: RollupPlacement;
|
|
25
|
+
summary: string;
|
|
26
|
+
}): string {
|
|
27
|
+
if (a.placement === "hybrid") return `⇄ hybrid: ${a.summary}`;
|
|
28
|
+
if (a.placement === "remote") return "☁ remote";
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A repo's suffix over its services: "☁ remote (dev)", "⇄ hybrid", or "". */
|
|
33
|
+
export function describeRepoPlacement(services: Placement[]): string {
|
|
34
|
+
const runs = new Set(services.map((s) => s.runs).filter((r) => r !== "none"));
|
|
35
|
+
if (runs.size > 1) return "⇄ hybrid";
|
|
36
|
+
if (!runs.has("remote")) return "";
|
|
37
|
+
const envs = [
|
|
38
|
+
...new Set(services.flatMap((s) => (s.target ? [s.target.env] : []))),
|
|
39
|
+
].sort();
|
|
40
|
+
return `☁ remote (${envs.join(", ")})`;
|
|
41
|
+
}
|
|
17
42
|
/** "feature-items (module) → ships in core-api" */
|
|
18
43
|
export function describeContributor(c: ApplicationContributor): string {
|
|
19
44
|
return `${c.repo} (${c.kind}) → ${c.contributesTo.length ? `ships in ${c.contributesTo.join(", ")}` : "no consumer known"}`;
|
|
@@ -92,15 +117,22 @@ export function cmdApps(args: string[]): void {
|
|
|
92
117
|
const owner = a.owner ? chalk.gray(` · ${a.owner}`) : "";
|
|
93
118
|
const kind =
|
|
94
119
|
a.source === "repo" ? chalk.gray(" (repo is the application)") : "";
|
|
95
|
-
|
|
120
|
+
const placed = describeAppPlacement(a);
|
|
121
|
+
console.log(
|
|
122
|
+
` ${chalk.bold.cyan(a.name)}${title}${owner}${kind}${placed ? chalk.cyan(` ${placed}`) : ""}`,
|
|
123
|
+
);
|
|
96
124
|
if (a.description) console.log(chalk.gray(` ${a.description}`));
|
|
97
125
|
const width = Math.max(0, ...a.repos.map((r) => r.length));
|
|
98
126
|
for (const repo of a.repos) {
|
|
99
127
|
const subs = a.services[repo]
|
|
100
128
|
.map((addr) => (addr.includes("/") ? addr.split("/")[1] : addr))
|
|
101
129
|
.filter((s) => s !== repo);
|
|
130
|
+
const where = describeRepoPlacement(
|
|
131
|
+
a.services[repo].flatMap((addr) => a.placements[addr] ?? []),
|
|
132
|
+
);
|
|
133
|
+
const suffix = where ? chalk.cyan(` ${where}`) : "";
|
|
102
134
|
console.log(
|
|
103
|
-
` ${subs.length ? `${repo.padEnd(width)}${chalk.gray(` ${subs.join(", ")}`)}` : repo}`,
|
|
135
|
+
` ${subs.length ? `${repo.padEnd(width)}${chalk.gray(` ${subs.join(", ")}`)}` : repo}${suffix}`,
|
|
104
136
|
);
|
|
105
137
|
}
|
|
106
138
|
for (const c of a.contributors)
|
|
@@ -4,20 +4,18 @@
|
|
|
4
4
|
* This org's HTTPS edge: own CA + cert, routes at `<name>.<edge.domain>`.
|
|
5
5
|
*/
|
|
6
6
|
import chalk from "chalk";
|
|
7
|
-
import * as fs from "fs";
|
|
8
|
-
import * as path from "path";
|
|
9
7
|
import { readBrand } from "../../shared/brand.js";
|
|
10
8
|
import { readEdgeConfig } from "../../shared/edge/config.js";
|
|
11
9
|
import { edgeDomains, edgeNames, routeUrl } from "../../shared/edge/render.js";
|
|
12
10
|
import {
|
|
13
11
|
caDir,
|
|
14
12
|
caTrusted,
|
|
15
|
-
containerState,
|
|
16
13
|
edgeDown,
|
|
17
14
|
edgeUp,
|
|
18
15
|
trustCa,
|
|
19
16
|
untrustCa,
|
|
20
17
|
} from "../../shared/edge/runtime.js";
|
|
18
|
+
import { edgeStatus } from "../../shared/edge/status.js";
|
|
21
19
|
|
|
22
20
|
export async function cmdEdge(args: string[]): Promise<void> {
|
|
23
21
|
const sub = args[0] ?? "status";
|
|
@@ -60,27 +58,19 @@ export async function cmdEdge(args: string[]): Promise<void> {
|
|
|
60
58
|
process.exitCode = untrustCa(cfg);
|
|
61
59
|
return;
|
|
62
60
|
case "routes":
|
|
63
|
-
for (const
|
|
64
|
-
console.log(`${
|
|
61
|
+
for (const r of edgeStatus(cfg).routes)
|
|
62
|
+
console.log(`${r.url.padEnd(52)} → ${r.upstream}`);
|
|
65
63
|
return;
|
|
66
64
|
case "status": {
|
|
67
|
-
|
|
68
|
-
console.log(
|
|
69
|
-
|
|
70
|
-
);
|
|
65
|
+
const st = edgeStatus(cfg);
|
|
66
|
+
console.log(chalk.bold(`${readBrand().name} edge — ${st.org}`));
|
|
67
|
+
console.log(` container ${st.container}: ${st.state}`);
|
|
71
68
|
console.log(` ports ${published}`);
|
|
69
|
+
console.log(` domains ${st.domains.join(", ")} (+ every subdomain)`);
|
|
72
70
|
console.log(
|
|
73
|
-
`
|
|
74
|
-
);
|
|
75
|
-
const trusted = caTrusted(cfg);
|
|
76
|
-
console.log(
|
|
77
|
-
` CA ${caDir(cfg)} — ${trusted === null ? "unknown" : trusted ? "trusted" : "NOT trusted"}`,
|
|
71
|
+
` CA ${caDir(cfg)} — ${st.caTrusted === null ? "unknown" : st.caTrusted ? "trusted" : "NOT trusted"}`,
|
|
78
72
|
);
|
|
79
|
-
|
|
80
|
-
if (fs.existsSync(stamp))
|
|
81
|
-
console.log(
|
|
82
|
-
` cert ${fs.readFileSync(stamp, "utf-8").split("\n").join(", ")}`,
|
|
83
|
-
);
|
|
73
|
+
if (st.certIssued) console.log(` cert ${st.certNames.join(", ")}`);
|
|
84
74
|
return;
|
|
85
75
|
}
|
|
86
76
|
default:
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
} from "../../shared/environment-manager/workspaces.js";
|
|
24
24
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
25
25
|
import { readManifest } from "../../shared/manifest/read-manifest.js";
|
|
26
|
+
import {
|
|
27
|
+
repoPlacement,
|
|
28
|
+
type RollupPlacement,
|
|
29
|
+
} from "../../shared/manifest/placement.js";
|
|
26
30
|
import type { AliasResolver } from "../../shared/environment-manager/derivation/alias.js";
|
|
27
31
|
import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
|
|
28
32
|
import {
|
|
@@ -170,6 +174,8 @@ export function renderText(
|
|
|
170
174
|
annotations?: Map<string, string>;
|
|
171
175
|
/** Node for the repo the command was run from — marked with a leading `*`. */
|
|
172
176
|
currentRepo?: string;
|
|
177
|
+
/** repo → placement (R13): remote and hybrid nodes are marked. */
|
|
178
|
+
placements?: Map<string, NodePlacement>;
|
|
173
179
|
} = {},
|
|
174
180
|
): string {
|
|
175
181
|
const showEvidence = opts.showEvidence ?? true;
|
|
@@ -197,7 +203,10 @@ export function renderText(
|
|
|
197
203
|
// green — both bold so they stand out from the plain-white edge bullets.
|
|
198
204
|
const isOff = !!ann && ann.includes("auto-start off");
|
|
199
205
|
const paint = isOff ? chalk.yellow : chalk.greenBright;
|
|
200
|
-
const
|
|
206
|
+
const placed = placementMark(opts.placements?.get(node));
|
|
207
|
+
const styledAnn =
|
|
208
|
+
(placed ? " " + chalk.cyan(placed) : "") +
|
|
209
|
+
(ann ? " " + paint(ann) : "");
|
|
201
210
|
// The repo the command was run from gets a leading `▸`; others a 2-space
|
|
202
211
|
// gutter so node names stay aligned. A single-width glyph (not an emoji,
|
|
203
212
|
// which is double-width + terminal-dependent) keeps the column stable.
|
|
@@ -229,9 +238,45 @@ export function renderText(
|
|
|
229
238
|
return lines.join("\n");
|
|
230
239
|
}
|
|
231
240
|
|
|
232
|
-
|
|
241
|
+
/** A repo's placement on the graph: local, remote (with envs), or hybrid. */
|
|
242
|
+
export interface NodePlacement {
|
|
243
|
+
placement: RollupPlacement;
|
|
244
|
+
envs: string[];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** "☁ remote (dev)", "⇄ hybrid", or "" for a local (or runtime-less) node. */
|
|
248
|
+
export function placementMark(p: NodePlacement | undefined): string {
|
|
249
|
+
if (!p) return "";
|
|
250
|
+
if (p.placement === "remote") return `☁ remote (${p.envs.join(", ")})`;
|
|
251
|
+
if (p.placement === "hybrid") return "⇄ hybrid";
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Each node's placement from its manifest, viewed locally. */
|
|
256
|
+
export function nodePlacements(nodes: string[]): Map<string, NodePlacement> {
|
|
257
|
+
const ctx = buildContext();
|
|
258
|
+
const out = new Map<string, NodePlacement>();
|
|
259
|
+
for (const node of nodes) {
|
|
260
|
+
const src = repoSource(node, ctx);
|
|
261
|
+
if (!src?.exists) continue;
|
|
262
|
+
out.set(node, repoPlacement(readManifest(src.path), "local"));
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function renderDot(
|
|
268
|
+
graph: DerivedGraph,
|
|
269
|
+
placements?: Map<string, NodePlacement>,
|
|
270
|
+
): string {
|
|
233
271
|
const lines = [`digraph ${product().cli} {`, " rankdir=LR;"];
|
|
234
|
-
for (const node of graph.nodes)
|
|
272
|
+
for (const node of graph.nodes) {
|
|
273
|
+
const p = placements?.get(node);
|
|
274
|
+
lines.push(
|
|
275
|
+
p?.placement === "remote"
|
|
276
|
+
? ` "${node}" [style=dashed, label="${node} ☁ ${p.envs.join(", ")}"];`
|
|
277
|
+
: ` "${node}";`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
235
280
|
for (const e of graph.edges) {
|
|
236
281
|
const style = e.kind === "event" ? "dashed" : "solid";
|
|
237
282
|
const color = e.blocking === "hard" ? "red" : "black";
|
|
@@ -442,7 +487,7 @@ export async function cmdGraph(args: string[]): Promise<void> {
|
|
|
442
487
|
return;
|
|
443
488
|
}
|
|
444
489
|
if (flags.format === "dot") {
|
|
445
|
-
console.log(renderDot(filtered));
|
|
490
|
+
console.log(renderDot(filtered, nodePlacements(filtered.nodes)));
|
|
446
491
|
return;
|
|
447
492
|
}
|
|
448
493
|
|
|
@@ -458,6 +503,7 @@ export async function cmdGraph(args: string[]): Promise<void> {
|
|
|
458
503
|
renderText(filtered, {
|
|
459
504
|
annotations: bootAnnotations(graph.nodes),
|
|
460
505
|
currentRepo,
|
|
506
|
+
placements: nodePlacements(graph.nodes),
|
|
461
507
|
}),
|
|
462
508
|
);
|
|
463
509
|
console.log("\n" + renderTargetWarnings(graph.nodes));
|