@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
@@ -8,6 +8,9 @@
8
8
  * copy is used: `manifests/<github-repo>.den.yml` in the Den repo, keyed by
9
9
  * the checkout's `origin` repo name. That covers repos whose manifest is not
10
10
  * merged yet and repos Den should not commit to.
11
+ *
12
+ * Whichever base won, `manifests/<github-repo>.override.yml` (when present)
13
+ * is deep-merged on top: see merge-override.ts for the rules.
11
14
  */
12
15
 
13
16
  import * as fs from "fs";
@@ -18,6 +21,7 @@ import { parse as parseYaml } from "yaml";
18
21
  import type { RawManifest } from "../types/index.js";
19
22
 
20
23
  import { product } from "../product.js";
24
+ import { mergeOverride } from "./merge-override.js";
21
25
  /** Names probed in order. First match wins. */
22
26
  const MANIFEST_NAMES = [
23
27
  `${product().manifestYml}`,
@@ -76,59 +80,179 @@ function managedCloneRepo(repoPath: string): string | null {
76
80
  : null;
77
81
  }
78
82
 
79
- /** The den-held manifest for `repoPath`, or null. */
80
- export function heldManifestPath(repoPath: string): string | null {
81
- const keys = [
83
+ /** The names `repoPath` is known by: origin repo, managed clone, folder. */
84
+ function repoKeys(repoPath: string): string[] {
85
+ return [
82
86
  originRepoName(repoPath),
83
87
  // A managed clone lives at .den-services/<repo>/<branch>,
84
88
  // and need not be cloned yet.
85
89
  managedCloneRepo(repoPath),
86
90
  path.basename(repoPath),
87
- ];
88
- for (const key of keys) {
89
- if (!key) continue;
90
- const p = path.join(heldManifestsDir(), `${key}${product().manifestYml}`);
91
+ ].filter((k): k is string => !!k);
92
+ }
93
+
94
+ /** The first `manifests/<key><suffix>` that exists, or null. */
95
+ function heldFile(repoPath: string, suffix: string): string | null {
96
+ for (const key of repoKeys(repoPath)) {
97
+ const p = path.join(heldManifestsDir(), `${key}${suffix}`);
91
98
  if (fs.existsSync(p)) return p;
92
99
  }
93
100
  return null;
94
101
  }
95
102
 
103
+ /** The den-held manifest for `repoPath`, or null. */
104
+ export function heldManifestPath(repoPath: string): string | null {
105
+ return heldFile(repoPath, `${product().manifestYml}`);
106
+ }
107
+
108
+ /**
109
+ * The override suffix: `.override` plus the manifest's extension, so a
110
+ * profile whose manifest is `.acme.yml` reads `manifests/<repo>.override.yml`.
111
+ */
112
+ export function overrideSuffix(): string {
113
+ return `.override${path.extname(product().manifestYml) || ".yml"}`;
114
+ }
115
+
96
116
  /**
97
- * Resolve the manifest for `repoPath`: the repo's own .den.yml (or
117
+ * The override for `repoPath` (`manifests/<repo>.override.yml`), or null.
118
+ * Matched by the same names, in the same order, as the held manifest.
119
+ */
120
+ export function overrideManifestPath(repoPath: string): string | null {
121
+ return heldFile(repoPath, overrideSuffix());
122
+ }
123
+
124
+ /**
125
+ * Resolve the base manifest for `repoPath`: the repo's own .den.yml (or
98
126
  * .json), else the one den holds for it. Returns null if neither exists.
127
+ * An override is layered on top by readManifest; it is never a base.
99
128
  */
100
129
  export function resolveManifestPath(repoPath: string): string | null {
101
130
  return repoManifestPath(repoPath) ?? heldManifestPath(repoPath);
102
131
  }
103
132
 
104
- /** Where `repoPath`'s manifest comes from. */
133
+ /** Where `repoPath`'s base manifest comes from. */
105
134
  export function manifestSource(repoPath: string): "repo" | "held" | null {
106
135
  if (repoManifestPath(repoPath)) return "repo";
107
136
  return heldManifestPath(repoPath) ? "held" : null;
108
137
  }
109
138
 
139
+ // ── Layers ───────────────────────────────────────────────────────────────
140
+
141
+ /** A layer of a resolved manifest. The base comes first. */
142
+ export type ManifestLayer = "repo" | "held" | "override";
143
+
144
+ export interface ManifestLoad {
145
+ /** The merged manifest, or null when none is usable. */
146
+ manifest: RawManifest | null;
147
+ /** The layers that applied, base first: e.g. ["repo", "override"]. */
148
+ layers: ManifestLayer[];
149
+ /** The base manifest file (repo or held), or null. */
150
+ baseFile: string | null;
151
+ /** The base's layer, or null when there is no base. */
152
+ baseLayer: "repo" | "held" | null;
153
+ /** The override file, or null. */
154
+ overrideFile: string | null;
155
+ /** Why no manifest is usable, when files exist but cannot be used. */
156
+ error?: string;
157
+ }
158
+
159
+ /** Parse a manifest file's text: a map, or null for anything else. */
160
+ export function parseManifestText(
161
+ filePath: string,
162
+ raw: string,
163
+ ): Record<string, unknown> | null {
164
+ const parsed: unknown = filePath.endsWith(".json")
165
+ ? JSON.parse(raw)
166
+ : parseYaml(raw);
167
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
168
+ return null; // scalar/array/empty manifest is not usable
169
+ return parsed as Record<string, unknown>;
170
+ }
171
+
172
+ /** The message for an override that has no base to apply to. */
173
+ export function orphanOverrideMessage(): string {
174
+ return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
175
+ }
176
+
177
+ interface Located {
178
+ baseFile: string | null;
179
+ baseLayer: "repo" | "held" | null;
180
+ overrideFile: string | null;
181
+ }
182
+
183
+ function locate(repoPath: string): Located {
184
+ const own = repoManifestPath(repoPath);
185
+ const held = own ? null : heldManifestPath(repoPath);
186
+ return {
187
+ baseFile: own ?? held,
188
+ baseLayer: own ? "repo" : held ? "held" : null,
189
+ overrideFile: overrideManifestPath(repoPath),
190
+ };
191
+ }
192
+
193
+ /** Parse and merge the located files' texts. Never throws. */
194
+ function combine(
195
+ at: Located,
196
+ baseRaw: string | null,
197
+ overrideRaw: string | null,
198
+ ): ManifestLoad {
199
+ const out: ManifestLoad = { manifest: null, layers: [], ...at };
200
+ const parse = (file: string, raw: string): Record<string, unknown> => {
201
+ const parsed = parseManifestText(file, raw);
202
+ if (!parsed) throw new Error(`${file}: not a map`);
203
+ return parsed;
204
+ };
205
+ if (!at.baseFile || !at.baseLayer || baseRaw === null) {
206
+ if (at.overrideFile)
207
+ out.error = `${at.overrideFile}: ${orphanOverrideMessage()}`;
208
+ return out;
209
+ }
210
+ try {
211
+ let manifest = parse(at.baseFile, baseRaw);
212
+ const layers: ManifestLayer[] = [at.baseLayer];
213
+ if (at.overrideFile && overrideRaw !== null) {
214
+ manifest = mergeOverride(manifest, parse(at.overrideFile, overrideRaw));
215
+ layers.push("override");
216
+ }
217
+ out.manifest = manifest as unknown as RawManifest;
218
+ out.layers = layers;
219
+ } catch (e) {
220
+ out.error = (e as Error).message;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
226
+ export function loadManifest(repoPath: string): ManifestLoad {
227
+ const at = locate(repoPath);
228
+ try {
229
+ return combine(
230
+ at,
231
+ at.baseFile && fs.readFileSync(at.baseFile, "utf-8"),
232
+ at.overrideFile && fs.readFileSync(at.overrideFile, "utf-8"),
233
+ );
234
+ } catch (e) {
235
+ return { manifest: null, layers: [], ...at, error: (e as Error).message };
236
+ }
237
+ }
238
+
239
+ /** The layers of `repoPath`'s manifest that applied, e.g. ["held", "override"]. */
240
+ export function manifestLayers(repoPath: string): ManifestLayer[] {
241
+ return loadManifest(repoPath).layers;
242
+ }
243
+
244
+ /** "repo", "held + override", … ("none" when no manifest applied). */
245
+ export function manifestLayersLabel(layers: readonly ManifestLayer[]): string {
246
+ return layers.length > 0 ? layers.join(" + ") : "none";
247
+ }
248
+
110
249
  /**
111
- * Read and parse the manifest from `repoPath`.
250
+ * Read and parse the manifest from `repoPath`, its override merged in.
112
251
  * Supports both YAML and JSON.
113
252
  * Returns null if no manifest file exists or parsing fails.
114
253
  */
115
254
  export function readManifest(repoPath: string): RawManifest | null {
116
- const filePath = resolveManifestPath(repoPath);
117
- if (!filePath) return null;
118
- try {
119
- const raw = fs.readFileSync(filePath, "utf-8");
120
- const parsed = filePath.endsWith(".yml") ? parseYaml(raw) : JSON.parse(raw);
121
- if (
122
- parsed === null ||
123
- typeof parsed !== "object" ||
124
- Array.isArray(parsed)
125
- ) {
126
- return null; // scalar/array/empty manifest is not usable
127
- }
128
- return parsed as RawManifest;
129
- } catch {
130
- return null;
131
- }
255
+ return loadManifest(repoPath).manifest;
132
256
  }
133
257
 
134
258
  /**
@@ -137,20 +261,14 @@ export function readManifest(repoPath: string): RawManifest | null {
137
261
  export async function readManifestAsync(
138
262
  repoPath: string,
139
263
  ): Promise<RawManifest | null> {
140
- const filePath = resolveManifestPath(repoPath);
141
- if (!filePath) return null;
264
+ const at = locate(repoPath);
142
265
  try {
143
266
  const { readFile } = await import("fs/promises");
144
- const raw = await readFile(filePath, "utf-8");
145
- const parsed = filePath.endsWith(".yml") ? parseYaml(raw) : JSON.parse(raw);
146
- if (
147
- parsed === null ||
148
- typeof parsed !== "object" ||
149
- Array.isArray(parsed)
150
- ) {
151
- return null; // scalar/array/empty manifest is not usable
152
- }
153
- return parsed as RawManifest;
267
+ return combine(
268
+ at,
269
+ at.baseFile && (await readFile(at.baseFile, "utf-8")),
270
+ at.overrideFile && (await readFile(at.overrideFile, "utf-8")),
271
+ ).manifest;
154
272
  } catch {
155
273
  return null;
156
274
  }
@@ -90,7 +90,21 @@ const httpUrlString = z
90
90
  .string()
91
91
  .regex(/^https?:\/\/\S+$/, "must be an http(s) URL");
92
92
 
93
+ // Placement (R13): where a service runs per env, and where a remote one is.
94
+ // Env names are checked against the org's environments in placement.ts.
95
+ const statusCode = z.number().int().min(100).max(599);
96
+ const serviceTarget = z
97
+ .object({
98
+ env: z.string().min(1),
99
+ account: z.string().min(1).optional(),
100
+ url: httpUrlString.optional(),
101
+ expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
102
+ })
103
+ .strict();
104
+
93
105
  const serviceManagementConfig = z.object({
106
+ runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
107
+ target: serviceTarget.optional(),
94
108
  depends_on: z.array(dependsOnEntry).optional(),
95
109
  commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
96
110
  logs: z.record(z.string(), logSource).optional(),
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Validate a repo's manifest layer by layer: the base (repo or held), the
3
+ * override, then the merged result. Each layer's errors name its file.
4
+ *
5
+ * The override is partial, so it is not checked against the whole schema
6
+ * (it need not repeat `name`, say). Instead each value it sets is checked
7
+ * against the schema at that path, and every key it names must exist in the
8
+ * base (see overrideKeyErrors).
9
+ *
10
+ * The schema and the single-file validator come in as `SchemaTools`, so this
11
+ * file stays under the typecheck gate: schema.ts and validate-manifest.ts are
12
+ * still baseline-excluded (zod v4 drift), and importing them here would pull
13
+ * them back into the checked program. validate-manifest.ts wires them in and
14
+ * exports validateManifestLayers(repoPath).
15
+ */
16
+
17
+ import * as fs from "fs";
18
+ import { z } from "zod";
19
+ import type { ValidationError, ValidationResult } from "../types/index.js";
20
+ import {
21
+ isPlainMap,
22
+ mergeOverride,
23
+ overrideKeyErrors,
24
+ type ManifestMap,
25
+ } from "./merge-override.js";
26
+ import {
27
+ loadManifest,
28
+ orphanOverrideMessage,
29
+ parseManifestText,
30
+ type ManifestLayer,
31
+ } from "./read-manifest.js";
32
+
33
+ /** What layered validation needs from the schema module. */
34
+ export interface SchemaTools {
35
+ /** The current schema version and its schema. */
36
+ version: number;
37
+ schema: z.ZodType;
38
+ /** Validate one whole manifest (validateManifest). */
39
+ validate: (input: unknown) => ValidationResult;
40
+ /** Map Zod issues to ValidationErrors, as validateManifest does. */
41
+ issuesToErrors: (issues: z.core.$ZodIssue[]) => ValidationError[];
42
+ }
43
+
44
+ export interface LayerValidation {
45
+ layer: ManifestLayer | "merged";
46
+ /** The layer's file; null for the merged result. */
47
+ file: string | null;
48
+ result: ValidationResult;
49
+ }
50
+
51
+ export interface LayeredValidation {
52
+ valid: boolean;
53
+ /**
54
+ * Base first, then override, then merged (only when there is an override
55
+ * and both layers pass on their own). Empty when no manifest file exists.
56
+ */
57
+ layers: LayerValidation[];
58
+ }
59
+
60
+ /** Strip optional/nullable/default wrappers to the schema underneath. */
61
+ function unwrap(schema: z.ZodType): z.ZodType {
62
+ let s = schema;
63
+ for (;;) {
64
+ if (s instanceof z.ZodOptional || s instanceof z.ZodNullable)
65
+ s = s.unwrap() as z.ZodType;
66
+ else if (s instanceof z.ZodDefault) s = s.def.innerType as z.ZodType;
67
+ else return s;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Schema errors in `value`, an override's value at `at`, merging into
73
+ * `base` (the base's value there). Maps are walked key by key where the
74
+ * schema is an object or a record; anything else is checked whole (a map
75
+ * merged onto its base first, so a partial union member still passes).
76
+ */
77
+ function overrideSchemaErrors(
78
+ tools: SchemaTools,
79
+ schema: z.ZodType,
80
+ value: unknown,
81
+ base: unknown,
82
+ at: string[],
83
+ ): ValidationError[] {
84
+ if (value === null) return []; // a delete: overrideKeyErrors checks the key
85
+ const node = unwrap(schema);
86
+ const baseAt = (key: string): unknown =>
87
+ isPlainMap(base) ? base[key] : undefined;
88
+ if (isPlainMap(value) && node instanceof z.ZodObject) {
89
+ const shape = node.shape as Record<string, z.ZodType>;
90
+ return Object.entries(value).flatMap(([key, v]) =>
91
+ key in shape
92
+ ? overrideSchemaErrors(tools, shape[key], v, baseAt(key), [...at, key])
93
+ : [],
94
+ );
95
+ }
96
+ if (isPlainMap(value) && node instanceof z.ZodRecord) {
97
+ const inner = node.valueType as z.ZodType;
98
+ return Object.entries(value).flatMap(([key, v]) =>
99
+ overrideSchemaErrors(tools, inner, v, baseAt(key), [...at, key]),
100
+ );
101
+ }
102
+ const candidate =
103
+ isPlainMap(value) && isPlainMap(base) ? mergeOverride(base, value) : value;
104
+ const result = node.safeParse(candidate);
105
+ return result.success
106
+ ? []
107
+ : tools.issuesToErrors(
108
+ result.error.issues.map((i) => ({ ...i, path: [...at, ...i.path] })),
109
+ );
110
+ }
111
+
112
+ /** Validate an override against the schema and against its base's keys. */
113
+ export function validateOverride(
114
+ tools: SchemaTools,
115
+ override: unknown,
116
+ base: ManifestMap,
117
+ ): ValidationResult {
118
+ const { version } = tools;
119
+ if (!isPlainMap(override))
120
+ return {
121
+ valid: false,
122
+ version,
123
+ errors: [{ path: "", message: "an override must be a map" }],
124
+ warnings: [],
125
+ };
126
+ const errors = [
127
+ ...overrideKeyErrors(base, override),
128
+ ...overrideSchemaErrors(tools, tools.schema, override, base, []),
129
+ ];
130
+ return { valid: errors.length === 0, version, errors, warnings: [] };
131
+ }
132
+
133
+ function withFile(result: ValidationResult, file: string): ValidationResult {
134
+ const tag = (e: ValidationError): ValidationError => ({ ...e, file });
135
+ return {
136
+ ...result,
137
+ errors: result.errors.map(tag),
138
+ warnings: result.warnings.map(tag),
139
+ };
140
+ }
141
+
142
+ function failure(
143
+ version: number,
144
+ file: string,
145
+ message: string,
146
+ ): ValidationResult {
147
+ return {
148
+ valid: false,
149
+ version,
150
+ errors: [{ path: "", message, file }],
151
+ warnings: [],
152
+ };
153
+ }
154
+
155
+ /** Read and parse a layer file, or the reason it cannot be used. */
156
+ function readLayer(file: string): { value: unknown } | { error: string } {
157
+ try {
158
+ const raw = fs.readFileSync(file, "utf-8");
159
+ // A non-map (scalar, list, empty file) is passed on as is, so the
160
+ // validator reports it the way it reports any other bad manifest.
161
+ let value: unknown;
162
+ try {
163
+ value = parseManifestText(file, raw);
164
+ } catch (e) {
165
+ return { error: `could not parse: ${(e as Error).message}` };
166
+ }
167
+ return { value: value ?? (raw.trim() ? raw : null) };
168
+ } catch (e) {
169
+ return { error: `could not read: ${(e as Error).message}` };
170
+ }
171
+ }
172
+
173
+ /** Validate every manifest layer of `repoPath`, then the merged result. */
174
+ export function validateLayers(
175
+ tools: SchemaTools,
176
+ repoPath: string,
177
+ ): LayeredValidation {
178
+ const { baseFile, baseLayer, overrideFile } = loadManifest(repoPath);
179
+ const layers: LayerValidation[] = [];
180
+
181
+ if (!baseFile || !baseLayer) {
182
+ if (overrideFile)
183
+ layers.push({
184
+ layer: "override",
185
+ file: overrideFile,
186
+ result: failure(tools.version, overrideFile, orphanOverrideMessage()),
187
+ });
188
+ return { valid: false, layers };
189
+ }
190
+
191
+ const base = readLayer(baseFile);
192
+ const baseResult =
193
+ "error" in base
194
+ ? failure(tools.version, baseFile, base.error)
195
+ : withFile(tools.validate(base.value), baseFile);
196
+ layers.push({ layer: baseLayer, file: baseFile, result: baseResult });
197
+
198
+ if (overrideFile) {
199
+ const override = readLayer(overrideFile);
200
+ const baseMap = "value" in base && isPlainMap(base.value) ? base.value : {};
201
+ const overrideResult =
202
+ "error" in override
203
+ ? failure(tools.version, overrideFile, override.error)
204
+ : withFile(
205
+ validateOverride(tools, override.value, baseMap),
206
+ overrideFile,
207
+ );
208
+ layers.push({
209
+ layer: "override",
210
+ file: overrideFile,
211
+ result: overrideResult,
212
+ });
213
+ // The merged result is checked once both layers pass on their own, so a
214
+ // layer's error is reported once, against its own file.
215
+ if (
216
+ baseResult.valid &&
217
+ overrideResult.valid &&
218
+ "value" in override &&
219
+ isPlainMap(override.value)
220
+ )
221
+ layers.push({
222
+ layer: "merged",
223
+ file: null,
224
+ result: tools.validate(mergeOverride(baseMap, override.value)),
225
+ });
226
+ }
227
+
228
+ return { valid: layers.every((l) => l.result.valid), layers };
229
+ }
@@ -13,6 +13,13 @@ import {
13
13
  schemaByVersion,
14
14
  } from "./schema.js";
15
15
  import type { ValidationError, ValidationResult } from "../types/index.js";
16
+ import { placementErrors } from "./placement.js";
17
+ import { builtinEnvironments, orgEnvironments } from "../den-config.js";
18
+ import {
19
+ validateLayers,
20
+ type LayeredValidation,
21
+ type SchemaTools,
22
+ } from "./validate-layers.js";
16
23
 
17
24
  // ── Helpers ───────────────────────────────────────────────────────────────────
18
25
 
@@ -56,6 +63,17 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
56
63
  return undefined;
57
64
  }
58
65
 
66
+ /** Map Zod issues to ValidationErrors. */
67
+ export function issuesToErrors(
68
+ issues: readonly z.ZodIssue[],
69
+ ): ValidationError[] {
70
+ return issues.map((issue) => ({
71
+ path: zodPathToString(issue.path as (string | number)[]),
72
+ message: zodIssueMessage(issue),
73
+ suggestion: suggestionFor(issue),
74
+ }));
75
+ }
76
+
59
77
  // ── Main export ───────────────────────────────────────────────────────────────
60
78
 
61
79
  /**
@@ -64,7 +82,26 @@ function suggestionFor(issue: z.ZodIssue): string | undefined {
64
82
  * @param input - A parsed object or a raw YAML string.
65
83
  * @returns A ValidationResult with errors, warnings, and the detected version.
66
84
  */
67
- export function validateManifest(input: unknown): ValidationResult {
85
+ export interface ValidateOptions {
86
+ /** The org's environments; default: the org config's (see orgEnvironments). */
87
+ environments?: readonly string[];
88
+ }
89
+
90
+ function environmentsFor(opts: ValidateOptions): readonly string[] {
91
+ if (opts.environments) return opts.environments;
92
+ try {
93
+ return orgEnvironments();
94
+ } catch {
95
+ // An unreadable org config is reported where it is read; validate the
96
+ // manifest against Den's built-in environment list meanwhile.
97
+ return builtinEnvironments();
98
+ }
99
+ }
100
+
101
+ export function validateManifest(
102
+ input: unknown,
103
+ opts: ValidateOptions = {},
104
+ ): ValidationResult {
68
105
  // 1. Parse YAML string if needed
69
106
  let parsed: unknown = input;
70
107
  if (typeof input === "string") {
@@ -116,6 +153,15 @@ export function validateManifest(input: unknown): ValidationResult {
116
153
  const result = schema.safeParse(parsed);
117
154
 
118
155
  if (result.success) {
156
+ // Env names in `runs` / `target.env` must be the org's (R13).
157
+ const placement = placementErrors(parsed, environmentsFor(opts));
158
+ if (placement.length > 0)
159
+ return {
160
+ valid: false,
161
+ version: detectedVersion,
162
+ errors: placement,
163
+ warnings,
164
+ };
119
165
  return {
120
166
  valid: true,
121
167
  version: detectedVersion,
@@ -125,11 +171,7 @@ export function validateManifest(input: unknown): ValidationResult {
125
171
  }
126
172
 
127
173
  // 6. Map Zod issues → ValidationError[]
128
- const errors: ValidationError[] = result.error.issues.map((issue) => ({
129
- path: zodPathToString(issue.path),
130
- message: zodIssueMessage(issue),
131
- suggestion: suggestionFor(issue),
132
- }));
174
+ const errors = issuesToErrors(result.error.issues);
133
175
 
134
176
  return {
135
177
  valid: false,
@@ -138,3 +180,21 @@ export function validateManifest(input: unknown): ValidationResult {
138
180
  warnings,
139
181
  };
140
182
  }
183
+
184
+ // ── Layers ────────────────────────────────────────────────────────────────────
185
+
186
+ const schemaTools: SchemaTools = {
187
+ version: CURRENT_SCHEMA_VERSION,
188
+ schema: schemaByVersion[CURRENT_SCHEMA_VERSION],
189
+ validate: (input) => validateManifest(input),
190
+ issuesToErrors: (issues) => issuesToErrors(issues as z.ZodIssue[]),
191
+ };
192
+
193
+ /**
194
+ * Validate each manifest layer of the repo at `repoPath` (its own or the
195
+ * held manifest, then manifests/<repo>.override.yml), then the merge.
196
+ * See validate-layers.ts.
197
+ */
198
+ export function validateManifestLayers(repoPath: string): LayeredValidation {
199
+ return validateLayers(schemaTools, repoPath);
200
+ }
@@ -147,6 +147,18 @@ export function getEngineRoot(): string {
147
147
  );
148
148
  }
149
149
 
150
+ /** Den's own version (its package.json), or undefined when unreadable. */
151
+ export function engineVersion(): string | undefined {
152
+ try {
153
+ const pkg = JSON.parse(
154
+ fs.readFileSync(path.join(getEngineRoot(), "package.json"), "utf-8"),
155
+ ) as { version?: unknown };
156
+ return typeof pkg.version === "string" ? pkg.version : undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
150
162
  /** This distribution's profile. */
151
163
  export function getProfile(): ProductProfile {
152
164
  return loadProfile(getDistRoot());