@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
@@ -16,7 +16,8 @@ import {
16
16
  spawnInteractive,
17
17
  getServiceLogPath,
18
18
  } from "../process/spawn-streaming.js";
19
- import { readUserConfig } from "../den-config.js";
19
+ import { isProductionEnv, readUserConfig } from "../den-config.js";
20
+ import { placementRefusal, servicePlacement } from "../manifest/placement.js";
20
21
  import { lookupTab, recordTab, forgetTab } from "../cli-tabs.js";
21
22
  import {
22
23
  closeTab,
@@ -1056,6 +1057,20 @@ export function runServiceCommand(
1056
1057
 
1057
1058
  const { repoPath, subsystem } = resolved;
1058
1059
 
1060
+ // R13: Den never runs a command on a remote service. A down stops nothing
1061
+ // here, which is a success; anything else is refused.
1062
+ const refusal = placementRefusal(
1063
+ resolved.address,
1064
+ servicePlacement(readManifest(repoPath), subsystem, "local"),
1065
+ (e) => isProductionEnv(e) === true,
1066
+ command,
1067
+ );
1068
+ if (refusal) {
1069
+ onLine(refusal);
1070
+ closeAsync(command === "down" ? 0 : 1);
1071
+ return dummy();
1072
+ }
1073
+
1059
1074
  // ── Up: share-if-identical / hard-block on conflict ─────────────────────────
1060
1075
  if (command === "up" || command === "restart") {
1061
1076
  let existing = findDaemonByAddress(resolved.address);
@@ -1079,7 +1094,9 @@ export function runServiceCommand(
1079
1094
  workspace,
1080
1095
  ];
1081
1096
  onLine(
1082
- `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1097
+ command === "restart"
1098
+ ? `restarting the running instance (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`
1099
+ : `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1083
1100
  );
1084
1101
  // restart still needs to bounce; only short-circuit pure `up`.
1085
1102
  if (command === "up") {
@@ -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,333 @@
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
+ /** The status codes that count as up for `url`, as declared. */
38
+ expect?: number | number[];
39
+ }
40
+
41
+ export interface Placement {
42
+ runs: Runs;
43
+ target: PlacementTarget | null;
44
+ }
45
+
46
+ /** Status codes that count as up for a target.url probe. */
47
+ export type Expect = readonly number[] | "2xx";
48
+
49
+ /** Is this env treated as production by the org? */
50
+ export type IsProduction = (env: string) => boolean;
51
+
52
+ const LOCAL = "local";
53
+
54
+ function isHttp(h: ServiceHealthConfig | undefined): h is { url: string } {
55
+ return !!h && h.type !== "shell" && typeof h.url === "string";
56
+ }
57
+
58
+ function hasCommands(
59
+ manifest: RawManifest,
60
+ svc: ServiceManagementConfig,
61
+ env: string,
62
+ ): boolean {
63
+ if (ownCommands(svc, env)) return true;
64
+ if (env !== LOCAL) return false;
65
+ // Every service of a compose repo runs in the local stack, and so does
66
+ // every service of a repo whose own up runs here: one repo-level up starts
67
+ // services that declare no commands of their own.
68
+ return (
69
+ !!manifest.compose ||
70
+ Object.values(manifest.services ?? {}).some((s) => ownCommands(s, env))
71
+ );
72
+ }
73
+
74
+ function ownCommands(svc: ServiceManagementConfig, env: string): boolean {
75
+ const cmds = svc.commands?.[env as keyof typeof svc.commands];
76
+ return !!cmds && Object.keys(cmds).length > 0;
77
+ }
78
+
79
+ function targetFor(svc: ServiceManagementConfig, env: string): PlacementTarget {
80
+ const t = svc.target;
81
+ const own = t && (env === LOCAL || t.env === env) ? t : undefined;
82
+ const tEnv = own?.env ?? env;
83
+ const health = svc.health?.[tEnv as keyof typeof svc.health];
84
+ const url = own?.url ?? (isHttp(health) ? health.url : undefined);
85
+ return {
86
+ env: tEnv,
87
+ ...(own?.account ? { account: own.account } : {}),
88
+ ...(url ? { url } : {}),
89
+ ...(own?.url && own.expect !== undefined ? { expect: own.expect } : {}),
90
+ };
91
+ }
92
+
93
+ /** Where `service` of `manifest` runs when viewing `env`. */
94
+ export function servicePlacement(
95
+ manifest: RawManifest | null | undefined,
96
+ service: string,
97
+ env: string,
98
+ ): Placement {
99
+ const svc = manifest?.services?.[service];
100
+ if (!manifest || !svc) return { runs: "none", target: null };
101
+ const explicit = svc.runs?.[env as keyof typeof svc.runs];
102
+ if (explicit === "local") return { runs: "local", target: null };
103
+ if (explicit === "remote")
104
+ return { runs: "remote", target: targetFor(svc, env) };
105
+ if (hasCommands(manifest, svc, env)) return { runs: "local", target: null };
106
+ if (isHttp(svc.health?.[env as keyof typeof svc.health]))
107
+ return { runs: "remote", target: targetFor(svc, env) };
108
+ return { runs: "none", target: null };
109
+ }
110
+
111
+ /** "dev · AWS": where a remote service is, for labels. */
112
+ export function targetLabel(t: PlacementTarget): string {
113
+ return t.account ? `${t.env} · ${t.account}` : t.env;
114
+ }
115
+
116
+ /** "☁ remote (dev)" for a remote service; "" otherwise. */
117
+ export function placementSuffix(p: Placement): string {
118
+ return p.runs === "remote" && p.target ? `☁ remote (${p.target.env})` : "";
119
+ }
120
+
121
+ // ── Health ──────────────────────────────────────────────────────────────────
122
+
123
+ export interface HealthCheckPlan {
124
+ placement: Placement;
125
+ /** The check to run, or undefined for none. */
126
+ check?: ServiceHealthConfig;
127
+ /** Set when probing target.url: the codes that count as up. */
128
+ expect?: Expect;
129
+ /** A remote service's row label: its target ("dev · AWS"). */
130
+ label?: string;
131
+ }
132
+
133
+ function expectOf(e: number | number[] | undefined): Expect {
134
+ if (e === undefined) return "2xx";
135
+ return Array.isArray(e) ? e : [e];
136
+ }
137
+
138
+ /**
139
+ * The health check for `service` viewed in `env`. The one resolution path for
140
+ * the web health row and `<cli> status`.
141
+ * - local / none: the env's declared check, else `composeShell` (locally);
142
+ * - remote: the check declared for target.env, else a GET of target.url
143
+ * (up when the status is in target.expect, default any 2xx).
144
+ */
145
+ export function resolveHealthCheck(
146
+ manifest: RawManifest | null | undefined,
147
+ service: string,
148
+ env: string,
149
+ composeShell: string | null,
150
+ ): HealthCheckPlan {
151
+ const placement = servicePlacement(manifest, service, env);
152
+ const svc = manifest?.services?.[service];
153
+ if (placement.runs === "remote" && placement.target) {
154
+ const t = placement.target;
155
+ const label = targetLabel(t);
156
+ const declared = svc?.health?.[t.env as keyof typeof svc.health];
157
+ if (declared) return { placement, check: declared, label };
158
+ if (svc?.target?.url && t.url === svc.target.url)
159
+ return {
160
+ placement,
161
+ check: { type: "http", url: t.url },
162
+ expect: expectOf(svc.target.expect),
163
+ label,
164
+ };
165
+ return { placement, label };
166
+ }
167
+ const declared = svc?.health?.[env as keyof typeof svc.health];
168
+ if (declared) return { placement, check: declared };
169
+ if (composeShell)
170
+ return { placement, check: { type: "shell", cmd: composeShell } };
171
+ return { placement };
172
+ }
173
+
174
+ /** Whether `status` counts as up under `expect`. */
175
+ export function expectMatches(
176
+ status: number | undefined,
177
+ expect: Expect,
178
+ ): boolean {
179
+ if (status === undefined) return false;
180
+ return expect === "2xx"
181
+ ? status >= 200 && status < 300
182
+ : expect.includes(status);
183
+ }
184
+
185
+ // ── Guards ──────────────────────────────────────────────────────────────────
186
+
187
+ /** A remote service whose target env counts as production. */
188
+ export function isProtectedRemote(p: Placement, isProduction: IsProduction) {
189
+ return p.runs === "remote" && !!p.target && isProduction(p.target.env);
190
+ }
191
+
192
+ /** How a refusal names the command: up → "start", exec → `run "exec" on`. */
193
+ function refusedVerb(command: string): string {
194
+ if (command === "up" || command === "start") return "start";
195
+ if (command === "down" || command === "stop") return "stop";
196
+ if (command === "restart") return "restart";
197
+ return `run "${command}" on`;
198
+ }
199
+
200
+ /**
201
+ * Why Den must not run `command` on `address`, or null. Every command on a
202
+ * remote service is refused ("runs in dev (AWS); nothing to restart here").
203
+ */
204
+ export function placementRefusal(
205
+ address: string,
206
+ p: Placement,
207
+ isProduction: IsProduction,
208
+ command = "up",
209
+ ): string | null {
210
+ const verb = refusedVerb(command);
211
+ if (p.runs !== "remote") return null;
212
+ const where = p.target
213
+ ? `${p.target.env}${p.target.account ? ` (${p.target.account})` : ""}`
214
+ : "a remote env";
215
+ const prod = isProtectedRemote(p, isProduction)
216
+ ? ", which counts as production: it is never acted on from here"
217
+ : "";
218
+ return `${address} runs in ${where}; nothing to ${verb} here${prod}`;
219
+ }
220
+
221
+ // ── Rollups ─────────────────────────────────────────────────────────────────
222
+
223
+ export type RollupPlacement = Runs | "hybrid";
224
+
225
+ export interface PlacementRow extends Placement {
226
+ repo: string;
227
+ service: string;
228
+ }
229
+
230
+ function rollup(runs: Runs[]): RollupPlacement {
231
+ const set = new Set(runs.filter((r) => r !== "none"));
232
+ if (set.size === 0) return "none";
233
+ return set.size > 1 ? "hybrid" : [...set][0];
234
+ }
235
+
236
+ function envsOf(rows: Placement[]): string[] {
237
+ return [
238
+ ...new Set(
239
+ rows.flatMap((r) =>
240
+ r.runs === "remote" && r.target ? [r.target.env] : [],
241
+ ),
242
+ ),
243
+ ].sort();
244
+ }
245
+
246
+ /**
247
+ * An application's placement and a summary per repo, e.g.
248
+ * "shop-frontend local · shop-core remote (dev)".
249
+ */
250
+ export function rollupPlacement(rows: readonly PlacementRow[]): {
251
+ placement: RollupPlacement;
252
+ summary: string;
253
+ } {
254
+ const byRepo = new Map<string, PlacementRow[]>();
255
+ for (const r of rows) {
256
+ if (!byRepo.has(r.repo)) byRepo.set(r.repo, []);
257
+ byRepo.get(r.repo)!.push(r);
258
+ }
259
+ const parts: string[] = [];
260
+ for (const [repo, list] of byRepo) {
261
+ const p = rollup(list.map((r) => r.runs));
262
+ if (p === "none") continue;
263
+ if (p === "local") parts.push(`${repo} local`);
264
+ else if (p === "remote")
265
+ parts.push(`${repo} remote (${envsOf(list).join(", ")})`);
266
+ else
267
+ parts.push(
268
+ `${repo} hybrid (${list
269
+ .filter((r) => r.runs === "remote")
270
+ .map((r) => `${r.service} remote (${r.target?.env ?? "?"})`)
271
+ .join(", ")})`,
272
+ );
273
+ }
274
+ return {
275
+ placement: rollup(rows.map((r) => r.runs)),
276
+ summary: parts.join(" · "),
277
+ };
278
+ }
279
+
280
+ /** A repo's placement over its manifest's services, and its remote envs. */
281
+ export function repoPlacement(
282
+ manifest: RawManifest | null | undefined,
283
+ env: string,
284
+ ): { placement: RollupPlacement; envs: string[] } {
285
+ const list = Object.keys(manifest?.services ?? {}).map((s) =>
286
+ servicePlacement(manifest, s, env),
287
+ );
288
+ return { placement: rollup(list.map((p) => p.runs)), envs: envsOf(list) };
289
+ }
290
+
291
+ // ── Validation ──────────────────────────────────────────────────────────────
292
+
293
+ function isMap(v: unknown): v is Record<string, unknown> {
294
+ return v !== null && typeof v === "object" && !Array.isArray(v);
295
+ }
296
+
297
+ /**
298
+ * Placement errors the schema cannot see: env names in `runs` and
299
+ * `target.env` must be among `environments` (the org's), and a service run
300
+ * remote locally must say where (`target`).
301
+ */
302
+ export function placementErrors(
303
+ manifest: unknown,
304
+ environments: readonly string[],
305
+ ): ValidationError[] {
306
+ if (!isMap(manifest) || !isMap(manifest.services)) return [];
307
+ const known = environments.join(", ");
308
+ const unknownEnv = (path: string, env: string): ValidationError => ({
309
+ path,
310
+ message: `unknown environment "${env}" (known: ${known})`,
311
+ suggestion: "Use one of the org's environments (config `environments:`).",
312
+ });
313
+ const errors: ValidationError[] = [];
314
+ for (const [name, svc] of Object.entries(manifest.services)) {
315
+ if (!isMap(svc)) continue;
316
+ const at = `services.${name}`;
317
+ const runs = isMap(svc.runs) ? svc.runs : {};
318
+ for (const env of Object.keys(runs))
319
+ if (!environments.includes(env))
320
+ errors.push(unknownEnv(`${at}.runs.${env}`, env));
321
+ const target = isMap(svc.target) ? svc.target : null;
322
+ if (target && typeof target.env === "string")
323
+ if (!environments.includes(target.env))
324
+ errors.push(unknownEnv(`${at}.target.env`, target.env));
325
+ if (runs[LOCAL] === "remote" && !target)
326
+ errors.push({
327
+ path: `${at}.target`,
328
+ message: "runs.local is remote: say where it runs with target.env",
329
+ suggestion: "e.g. target: { env: dev, url: https://…/health }",
330
+ });
331
+ }
332
+ return errors;
333
+ }