@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,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
- console.log(` ${chalk.bold.cyan(a.name)}${title}${owner}${kind}`);
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 [name, url] of Object.entries(cfg.routes))
64
- console.log(`${routeUrl(name, cfg).padEnd(52)} → ${url}`);
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
- console.log(chalk.bold(`${readBrand().name} edge — ${cfg.org}`));
68
- console.log(
69
- ` container ${n.container}: ${containerState(n.container)}`,
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
- ` domains ${edgeDomains(cfg).join(", ")} (+ every subdomain)`,
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
- const stamp = path.join(cfg.stateDir, "certs", "names.txt");
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 styledAnn = ann ? " " + paint(ann) : "";
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
- export function renderDot(graph: DerivedGraph): string {
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) lines.push(` "${node}";`);
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));