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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/index.mjs +298 -0
- 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
|
+
}
|