enablement-build-manifest 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/index.mjs +298 -0
  4. package/package.json +17 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Doty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # enablement-build-manifest
2
+
3
+ Scans a monorepo and produces a JSON manifest describing every package's version, local dependency graph, and CI/CD metadata. Supports both Azure DevOps (`.cicd/`) and GitHub Actions (`.github/`) layouts.
4
+
5
+ ## Usage
6
+
7
+ ```sh
8
+ npx enablement-build-manifest [options]
9
+ ```
10
+
11
+ Run from the root of your monorepo. The manifest is printed to stdout and optionally saved to disk.
12
+
13
+ ## Options
14
+
15
+ | Flag | Description |
16
+ |------|-------------|
17
+ | `--save` | Write the manifest to `{configDir}/manifest.json` in addition to stdout |
18
+ | `--platform <github\|ado>` | Override platform detection (default: auto-detected) |
19
+ | `--debug` | Print the number of discovered packages |
20
+
21
+ ## Platform Detection
22
+
23
+ The platform is auto-detected by looking for the following files:
24
+
25
+ - `.github/templates/templated.json` or `.github/manifest.json` → `github` (uses `.github/`)
26
+ - Neither found → `ado` (uses `.cicd/`)
27
+
28
+ Pass `--platform github` or `--platform ado` to override.
29
+
30
+ ## Output
31
+
32
+ The manifest is a JSON object with five top-level keys:
33
+
34
+ ```json
35
+ {
36
+ "versions": { "<package-name>": "<version>", ... },
37
+ "dependencies": { "<package-name>": ["<local-dep>", ...], ... },
38
+ "dependents": { "<package-name>": ["<consumer>", ...], ... },
39
+ "pipelines": { "<template-file>": [ <stage>, ... ], ... },
40
+ "cicd": { "<package-name>": { "output": "...", "language": "...", "tier": "...", ... }, ... }
41
+ }
42
+ ```
43
+
44
+ ### `versions`
45
+
46
+ Maps every local package name to its current version from `package.json`.
47
+
48
+ ### `dependencies` / `dependents`
49
+
50
+ - `dependencies` — the local packages each package directly depends on.
51
+ - `dependents` — the reverse: which local packages consume a given package.
52
+
53
+ ### `pipelines`
54
+
55
+ Populated when `{configDir}/templates/templated.json` exists. Each key is a pipeline template filename; the value is an ordered list of resolved stages with their associated package lists.
56
+
57
+ ### `cicd`
58
+
59
+ CI/CD metadata for each package. Sourced from the `.cicd` field in `package.json` when present; otherwise auto-detected by inspecting the package:
60
+
61
+ | Indicator | `output` | `language` | `tier` |
62
+ |-----------|----------|------------|--------|
63
+ | `main` or `module` field | `npm` | `JavaScript` | `component` |
64
+ | `bin` field (CLI tool) | `npm` | `JavaScript` | `tooling` |
65
+ | `moleculer` dependency | `npm` | `node` | `api` |
66
+ | `react` dependency | `npm` | `JavaScript` | `ux` |
67
+ | `*.csproj` present | `nuget` | `C#` | `api` |
68
+ | `pyproject.toml` present | `pypi` | `Python` | `api` |
69
+ | `Dockerfile` in image/container path | `docker` | `Dockerfile` | `infra` |
70
+
71
+ Template filenames are also resolved and attached to each `cicd` entry based on the package type.
72
+
73
+ ## Example
74
+
75
+ ```sh
76
+ # Print manifest to stdout
77
+ npx enablement-build-manifest
78
+
79
+ # Save to .cicd/manifest.json (ADO project)
80
+ npx enablement-build-manifest --save
81
+
82
+ # Save to .github/manifest.json (GitHub project)
83
+ npx enablement-build-manifest --save --platform github
84
+
85
+ # Debug: show discovered package count
86
+ npx enablement-build-manifest --debug
87
+ ```
package/index.mjs ADDED
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import { glob } from 'glob';
5
+ import YAML from 'yaml'
6
+ import path from 'path';
7
+
8
+ const main = async (options) => {
9
+
10
+ if (!options.platform) {
11
+ options.platform = (fs.existsSync('.github/templates/templated.json') || fs.existsSync('.github/manifest.json')) ? 'github' : 'ado';
12
+ }
13
+ const configDir = options.platform === 'github' ? '.github' : '.cicd';
14
+
15
+ // Find all package.json files, excluding node_modules
16
+ const packageJsonPaths = glob.sync('*/**/package.json', {
17
+ ignore: ['**/node_modules/**'],
18
+ cwd: process.cwd(),
19
+ absolute: true
20
+ });
21
+
22
+ const nameToPath = {};
23
+
24
+ const versions = {};
25
+ const dependencies = {};
26
+ const dependents = {};
27
+ const cicd = {};
28
+ const pipelines = {};
29
+
30
+ const packageData = {};
31
+ const stage2Packages = {};
32
+
33
+ // First pass: collect all local package names and versions
34
+ packageJsonPaths.forEach(pkgPath => {
35
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
36
+ packageData[pkgPath] = pkgJson;
37
+ if (pkgJson.name && pkgJson.version) {
38
+ nameToPath[pkgJson.name] = pkgPath;
39
+
40
+ // create map of stage to packages
41
+ const stageName = path.basename(path.dirname(path.dirname(pkgPath)));
42
+ if(!stage2Packages[stageName]) stage2Packages[stageName] = [];
43
+ stage2Packages[stageName].push(pkgJson.name);
44
+
45
+ versions[pkgJson.name] = pkgJson.version;
46
+ dependents[pkgJson.name] = []; // Initialize reverse dependency map
47
+ if (pkgJson['.cicd']) {
48
+ cicd[pkgJson.name] = pkgJson['.cicd'];
49
+ }
50
+ }
51
+ });
52
+
53
+ if(options.debug)
54
+ console.log(`Discovered ${Object.keys(nameToPath).length} local packages.`);
55
+
56
+
57
+ // Second pass: build dependency tree
58
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
59
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
60
+ const allDeps = {
61
+ ...pkgJson.dependencies,
62
+ ...pkgJson.devDependencies
63
+ };
64
+
65
+ const localDeps = Object.keys(allDeps || {}).filter(dep => nameToPath.hasOwnProperty(dep));
66
+ dependencies[pkgName] = localDeps;
67
+
68
+ // Populate reverse dependencies
69
+ localDeps.forEach(dep => {
70
+ if (!dependents[dep]) {
71
+ dependents[dep] = [];
72
+ }
73
+ dependents[dep].push(pkgName);
74
+ });
75
+ });
76
+
77
+ // now find all packages that do not have a .cicd entry
78
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
79
+ if (!cicd[pkgName]) {
80
+
81
+ var pkg = packageData[pkgPath];
82
+ if (pkg['main'] || pkg['module']) {
83
+ cicd[pkgName] = {
84
+ output: "npm",
85
+ language: 'JavaScript',
86
+ tier: 'component'
87
+ }
88
+ if(pkg["bin"]) {
89
+ cicd[pkgName]["packageType"] = "cli";
90
+ cicd[pkgName]["tier"] = "tooling";
91
+ }
92
+ return;
93
+ } else {
94
+ if ((pkg['devDependencies'] && pkg['devDependencies']['moleculer']) || (pkg['dependencies'] && pkg['dependencies']['moleculer'])) {
95
+ cicd[pkgName] = {
96
+ "output": "npm",
97
+ "language": "node",
98
+ "packageType": "moleculer",
99
+ "tier": "api"
100
+ }
101
+ return;
102
+ } else if ((pkg['devDependencies'] && pkg['devDependencies']['react']) || (pkg['dependencies'] && pkg['dependencies']['react'])) {
103
+ cicd[pkgName] = {
104
+ "output": "npm",
105
+ "language": "JavaScript",
106
+ "packageType": "ux",
107
+ "tier": "ux"
108
+ }
109
+ return;
110
+ } else {
111
+ // ok it is not node
112
+ var folderName = path.dirname(pkgPath);
113
+ const csproj = glob.sync('*.csproj', { cwd: folderName, absolute: true });
114
+ const toml = glob.sync('pyproject.toml', { cwd: folderName, absolute: true });
115
+ const docker = glob.sync('Dockerfile', { cwd: folderName, absolute: true });
116
+
117
+ if (csproj.length > 0) {
118
+ cicd[pkgName] = {
119
+ "output": "nuget",
120
+ "language": "C#",
121
+ "packageType": "dotnet",
122
+ "tier": "api"
123
+ }
124
+ return;
125
+ } else if (toml.length > 0) {
126
+ cicd[pkgName] = {
127
+ "output": "pypi",
128
+ "language": "Python",
129
+ "tier": "api"
130
+ }
131
+ if (pkgName.toLocaleLowerCase().endsWith("api")) cicd[pkgName]["packageType"] = "pypi";
132
+ return;
133
+ } else if (docker.length > 0 && (pkgPath.indexOf("image") >= 0 || pkgPath.indexOf("container") >= 0)) {
134
+ cicd[pkgName] = {
135
+ "output": "docker",
136
+ "language": "Dockerfile",
137
+ "tier": "infra"
138
+ }
139
+ return;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ });
145
+
146
+ // now find all packages where templates are missing
147
+ // first load the templated.json
148
+ let templated = {};
149
+ if (fs.existsSync(`./${configDir}/templates/templated.json`)) {
150
+ let data = JSON.parse(fs.readFileSync(`./${configDir}/templates/templated.json`, 'utf-8'));
151
+ let innerNames ={};
152
+ for (const outerKey in data) {
153
+ const innerObj = data[outerKey];
154
+ const innerKeys = Object.keys(innerObj);
155
+ innerKeys.forEach(innerKey =>{
156
+ let { rootPath, template } = innerObj[innerKey];
157
+ rootPath = rootPath.replace(/\/+$/,''); // remove trailing slash
158
+ rootPath = rootPath.replace(/\.\.\/+/g, ''); // remove leading ../
159
+
160
+ innerNames[innerKey] = rootPath;
161
+ templated[rootPath] = templated[rootPath] || [];
162
+ templated[rootPath].push(template);
163
+ });
164
+ }
165
+
166
+ for (const outerKey in data) {
167
+ if(!pipelines[outerKey]) {
168
+ let pipeline = pipelines[outerKey] = [];
169
+
170
+ let tpl = fs.readFileSync(`./${configDir}/templates/${outerKey}`, 'utf-8');
171
+
172
+ let i = 0;
173
+ for (const innerKey of Object.keys(innerNames)) {
174
+ const regex = new RegExp(`\\{\\s*${innerKey.toUpperCase()}\\s*\\}`, 'g');
175
+ if(i==0) tpl = tpl.replace(regex, ` blocks:\n - ${innerKey.toUpperCase()}`);
176
+ else tpl = tpl.replace(regex, ` - ${innerKey.toUpperCase()}`);
177
+ i++;
178
+ }
179
+
180
+ var yaml = YAML.parse(tpl);
181
+ for(const s of Object.keys(yaml.stages))
182
+ {
183
+ let stage = yaml.stages[s];
184
+
185
+ let blocks = stage.blocks;
186
+ if(blocks) {
187
+ for(const b of blocks) {
188
+ let pgkList= stage2Packages[innerNames[b]];
189
+ if(pgkList) stage.packages = pgkList;
190
+ }
191
+ }
192
+
193
+ delete stage.blocks;
194
+ delete stage.jobs;
195
+ delete stage.variables;
196
+ pipeline.push(stage);
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ const templates = glob.sync(`./${configDir}/templates/*.yml`);
203
+ const withUnderscore = templates
204
+ .filter(file => file.includes('_'))
205
+ .map(file => path.basename(file));
206
+
207
+ const codeScan = withUnderscore.filter(file => file.includes('scan') || file.includes('check'));
208
+ const build = withUnderscore.filter(file => !file.includes('scan') && !file.includes('check'));
209
+
210
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
211
+ if (cicd[pkgName] && !cicd[pkgName]["templates"]) {
212
+ const pkgDir = path.basename(path.dirname(path.dirname(pkgPath)));
213
+ if(templated[pkgDir]) {
214
+ cicd[pkgName]["templates"] = templated[pkgDir];
215
+ } else {
216
+ let lookfor="node";
217
+ if(cicd[pkgName]["output"]=="pypi") lookfor = "python";
218
+ if(cicd[pkgName]["output"]=="nuget") lookfor = "dotnet";
219
+ if(cicd[pkgName]["output"]=="docker") lookfor = "docker";
220
+
221
+ let templates=[];
222
+ build.forEach(file => {
223
+ if (file.includes(lookfor) ) {
224
+ templates.push(file);
225
+ }
226
+ });
227
+ let found = false;
228
+ codeScan.forEach(file => {
229
+ if (file.includes(lookfor) ) {
230
+ templates.push(file);
231
+ found = true;
232
+ }
233
+ });
234
+ if(!found && templates.length>0) templates.push("build_scan.yml");
235
+ cicd[pkgName]["templates"] = templates;
236
+ }
237
+ }
238
+ });
239
+
240
+ // Final output structure
241
+ const output = {
242
+ versions,
243
+ dependencies,
244
+ dependents,
245
+ pipelines,
246
+ cicd
247
+ };
248
+
249
+ const outputJson = JSON.stringify(output, null, 2)
250
+ if(options.save) fs.writeFileSync(`./${configDir}/manifest.json`, outputJson, 'utf-8');
251
+ return outputJson;
252
+ };
253
+
254
+ let options = {
255
+ debug:false,
256
+ platform:null,
257
+ hashExcludeFolders:['node_modules', 'coverage', 'dist'],
258
+ hashExcludeFiles:['.npmrc','CHANGELOG.md','README.md']
259
+ };
260
+
261
+ // if (process.argv.length === 2) {
262
+ // console.error('Expected at least one argument!');
263
+ // process.exit(1);
264
+ // } else {
265
+ let argv = process.argv;
266
+ for(let i=2;i<argv.length;i++) {
267
+ if(argv[i]==="--save") options.save=true;
268
+ else if(argv[i]==="--debug") options.debug=true;
269
+ else if(argv[i]==="--changed") options.changed=true;
270
+ else if(argv[i]==="--hash") options.hash=true;
271
+ else if(argv[i]==="--platform") { options.platform = argv[i+1]; i++; }
272
+ else if(argv[i]==="--hashExcludeFolders" || argv[i]==="--hashExcludeFiles") {
273
+ let name = argv[i].substring(2);
274
+ options[name] = argv[i+1].split(',');
275
+ i++;
276
+ } else
277
+ if(argv[i].substring(0,2)==="--") {
278
+ let name = argv[i].substring(2);
279
+ if(options[name]!==undefined) {
280
+ options[name] = argv[i+1];
281
+ i++;
282
+ } else {
283
+ console.error(`Expected a known option, got ${argv[i]}`);
284
+ process.exit(1);
285
+ }
286
+ }
287
+ }
288
+ //}
289
+
290
+ (async () => {
291
+ try {
292
+ const text = await main(options);
293
+ console.log(text);
294
+ } catch (e) {
295
+ console.log(e);
296
+ process.exit(1);
297
+ }
298
+ })();
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "enablement-build-manifest",
3
+ "version": "1.0.0",
4
+ "description": "This builds a dependency tree.",
5
+ "type": "module",
6
+ "module": "./index.mjs",
7
+ "bin": "./index.mjs",
8
+ "license": "MIT",
9
+ "author": "Contributors",
10
+ "dependencies": {
11
+ "glob": "^11.0.3",
12
+ "yaml": "^2.8.1"
13
+ },
14
+ "scripts": {
15
+ "start": "node index.mjs"
16
+ }
17
+ }