@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.
- package/CHANGELOG.md +137 -0
- package/README.md +1 -1
- package/bin/den +7 -0
- package/docs/distributions.md +255 -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 +16 -1
- 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 +321 -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 +131 -45
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -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 +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -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 +175 -61
- 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 +976 -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 +787 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/ui-assets.ts +73 -0
- 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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
141
|
-
if (!filePath) return null;
|
|
264
|
+
const at = locate(repoPath);
|
|
142
265
|
try {
|
|
143
266
|
const { readFile } = await import("fs/promises");
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/shared/paths.ts
CHANGED
|
@@ -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());
|