enablement-build-manifest 1.0.2 → 1.0.3

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.
@@ -0,0 +1,336 @@
1
+ trigger:
2
+ - main
3
+
4
+ parameters:
5
+ - name: publish_ado
6
+ displayName: Publish ADO
7
+ type: boolean
8
+ default: true
9
+
10
+ - name: publish_nexus
11
+ displayName: Publish Nexus
12
+ type: boolean
13
+ default: true
14
+
15
+ pool:
16
+ name: On-Prem Build Agents
17
+ demands:
18
+ - Agent.OS -equals Linux
19
+
20
+ resources:
21
+ repositories:
22
+ - repository: team
23
+ type: git
24
+ name: ECommerce/pipeline-templates
25
+ variables:
26
+ publish_nexus: ${{parameters.publish_nexus}}
27
+ publish_ado: ${{parameters.publish_ado}}
28
+ isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
29
+ dockerRoot: deploy
30
+ PIPELINE_NAME: enable-deploy-mono.GH.DEPLOY
31
+
32
+ stages:
33
+ - stage: Version
34
+ jobs:
35
+ - job: CheckForChanges
36
+ steps:
37
+ - checkout: self
38
+ persistCredentials: true
39
+ fetchTags: true,
40
+ fetchDepth: 0,
41
+ clean: true
42
+ - script: |
43
+ REPO=$(echo $(Build.Repository.Name)| cut -d'/' -f 2)
44
+ echo "##vso[task.setvariable variable=repo;isoutput=true;]$REPO"
45
+ BRANCH=$(echo $(Build.SourceBranchName))
46
+ echo $REPO/$BRANCH
47
+ echo "##vso[task.setvariable variable=env;isoutput=true;]DEV"
48
+
49
+ git config --global user.email "icg_it_linux@ingramcontent.com"
50
+ git config --global user.name "CICD"
51
+
52
+ npm i pnpm@10 nx@latest jest -g
53
+ nx graph --file dependencies.json
54
+ git clean -dfX
55
+ npm run release:changed
56
+ displayName: 'Get Changes'
57
+ name: getChanged
58
+
59
+ - stage: Build_Node
60
+ dependsOn: Version
61
+ displayName: Build Node
62
+ jobs:
63
+ - job: BuildChanged
64
+ pool:
65
+ name: 'On-Prem Build Agents'
66
+ demands:
67
+ - Agent.OS -equals Linux
68
+ timeoutInMinutes: 60
69
+ variables:
70
+ changed: $[stageDependencies.Version.CheckForChanges.outputs['getChanged.changed']]
71
+
72
+ steps:
73
+ - checkout: self
74
+ persistCredentials: true
75
+ fetchTags: true,
76
+ fetchDepth: 0,
77
+ clean: true
78
+ - script: |
79
+ echo $(changed)
80
+ cp ./.cicd/ado.npmrc ./.npmrc
81
+ displayName: 'ADO NPM Prepare'
82
+ condition: eq(variables.publish_ado, true)
83
+ - task: npmAuthenticate@0
84
+ inputs:
85
+ customEndpoint: 'ado-npm-icg,ado-npm-lit'
86
+ workingFile: ./.npmrc
87
+ displayName: 'ADO NPM Authentication'
88
+ condition: eq(variables.publish_ado, true)
89
+ - script: |
90
+ mv ./.npmrc ./.npmrc_ado
91
+ displayName: 'ADO NPM Save'
92
+ condition: eq(variables.publish_ado, true)
93
+
94
+ - script: |
95
+ cp ./.cicd/nexus.npmrc ./.npmrc
96
+ displayName: 'Nexus NPM Prepare'
97
+ condition: eq(variables.publish_nexus, true)
98
+ - task: npmAuthenticate@0
99
+ inputs:
100
+ customEndpoint: 'nexus-npm-hosted'
101
+ workingFile: ./.npmrc
102
+ displayName: 'Nexus NPM Authentication'
103
+ condition: eq(variables.publish_nexus, true)
104
+ - script: |
105
+ mv ./.npmrc ./.npmrc_nexus
106
+ displayName: 'Nexus NPM Save'
107
+ condition: eq(variables.publish_nexus, true)
108
+
109
+ - script: |
110
+ npm install -g nx pnpm@10
111
+ nx graph --file dependencies.json
112
+ npm run release:version
113
+ displayName: 'Get Versions'
114
+ name: getVersions
115
+
116
+ - script: |
117
+ cp ./.cicd/nexus-group.npmrc ./.npmrc
118
+ #rm -f pnpm-lock.yaml
119
+ pnpm i
120
+ name: INSTALL
121
+
122
+ - script: |
123
+ export NX_DISABLE_DB=true
124
+ pnpm run build
125
+ name: BUILD
126
+
127
+ - script: |
128
+ pnpm test
129
+ displayName: 'TEST'
130
+ name: tester
131
+
132
+ # - template: sonarqube-prepare.yml@team
133
+ # parameters:
134
+ # scannerMode: "other"
135
+
136
+ ####################PACKAGES
137
+ {PACKAGES}
138
+ ####################PACKAGES
139
+
140
+ ####################API
141
+ {API}
142
+ ####################API
143
+
144
+ ####################UX
145
+ {UX}
146
+ ####################UX
147
+
148
+ - stage: Build_Python
149
+ dependsOn: Version
150
+ displayName: Build Python
151
+ jobs:
152
+ - job: BuildChanged
153
+ pool:
154
+ name: 'On-Prem Build Agents'
155
+ demands:
156
+ - Agent.OS -equals Linux
157
+ timeoutInMinutes: 60
158
+ variables:
159
+ changed: $[stageDependencies.Version.CheckForChanges.outputs['getChanged.changed']]
160
+
161
+ steps:
162
+ - checkout: self
163
+ persistCredentials: true
164
+ fetchTags: true,
165
+ fetchDepth: 0,
166
+ clean: true
167
+
168
+ - script: |
169
+ npm install -g nx pnpm@10
170
+ nx graph --file dependencies.json
171
+ npm run release:version
172
+ displayName: 'Get Versions'
173
+ name: getVersions
174
+
175
+ # - script: |
176
+ # export NX_DISABLE_DB=true
177
+ # pnpm run build:python
178
+ # name: BUILD
179
+
180
+ # - script: |
181
+ # pnpm test:python
182
+ # displayName: 'TEST'
183
+ # name: tester
184
+
185
+ ####################PYTHON_PACKAGES
186
+ {PYTHON_PACKAGES}
187
+ ####################PYTHON_PACKAGES
188
+
189
+ - task: CopyFiles@2
190
+ displayName: 'Copy Lock Files to Staging'
191
+ inputs:
192
+ SourceFolder: '$(System.DefaultWorkingDirectory)'
193
+ Contents: |
194
+ **/poetry.lock
195
+ **/requirements.txt
196
+ **/pyproject.toml
197
+ TargetFolder: '$(Build.ArtifactStagingDirectory)'
198
+
199
+ - task: PublishPipelineArtifact@1
200
+ displayName: 'Publish Python Lock Files'
201
+ inputs:
202
+ targetPath: '$(Build.ArtifactStagingDirectory)'
203
+ artifactName: 'python-lock-files'
204
+ publishLocation: 'pipeline'
205
+
206
+
207
+ - stage: Build_DotNet
208
+ dependsOn: Version
209
+ displayName: Build DotNet
210
+ jobs:
211
+ - job: BuildChanged
212
+ pool:
213
+ name: 'On-Prem Build Agents'
214
+ demands:
215
+ - Agent.OS -equals Linux
216
+ timeoutInMinutes: 60
217
+ variables:
218
+ changed: $[stageDependencies.Version.CheckForChanges.outputs['getChanged.changed']]
219
+
220
+ steps:
221
+ - checkout: self
222
+ persistCredentials: true
223
+ fetchTags: true,
224
+ fetchDepth: 0,
225
+ clean: true
226
+
227
+ - script: |
228
+ npm install -g nx pnpm@10
229
+ nx graph --file dependencies.json
230
+ npm run release:version
231
+ displayName: 'Get Versions'
232
+ name: getVersions
233
+
234
+ ####################DOTNET_PACKAGES
235
+ {DOTNET_PACKAGES}
236
+ ####################DOTNET_PACKAGES
237
+
238
+ - stage: Build_Containers
239
+ dependsOn:
240
+ - Version
241
+ - Build_Python
242
+ - Build_Node
243
+ - Build_DotNet
244
+ displayName: Build Containers
245
+ jobs:
246
+ - job: BuildChanged
247
+ pool:
248
+ name: 'On-Prem Build Agents'
249
+ demands:
250
+ - Agent.OS -equals Linux
251
+ timeoutInMinutes: 60
252
+ variables:
253
+ changed: $[stageDependencies.Version.CheckForChanges.outputs['getChanged.changed']]
254
+
255
+ steps:
256
+ - checkout: self
257
+ persistCredentials: true
258
+ fetchTags: true,
259
+ fetchDepth: 0,
260
+ clean: true
261
+
262
+ - script: |
263
+ npm install -g nx pnpm@10
264
+ nx graph --file dependencies.json
265
+ npm run release:version
266
+ displayName: 'Get Versions'
267
+ name: getVersions
268
+
269
+ # - script: |
270
+ # pnpm test
271
+ # displayName: 'TEST'
272
+ # name: tester
273
+
274
+ ####################CONTAINERS
275
+ {CONTAINERS}
276
+ ####################CONTAINERS
277
+
278
+
279
+ - stage: Release
280
+ dependsOn:
281
+ - Version
282
+ - Build_Containers
283
+ - Build_Python
284
+ - Build_Node
285
+ - Build_DotNet
286
+
287
+ variables:
288
+ VERSION: $[ stageDependencies.Version.CheckForChanges.outputs['getChanged.tag'] ]
289
+ REPO: $[ stageDependencies.Version.CheckForChanges.outputs['getChanged.repo'] ]
290
+ ENV: $[ stageDependencies.Version.CheckForChanges.outputs['getChanged.env'] ]
291
+ BRANCH: $(Build.SourceBranchName)
292
+ jobs:
293
+ - job: Push
294
+ condition: and(succeeded(), eq(variables.isMain, true))
295
+ steps:
296
+ - checkout: self
297
+ persistCredentials: true
298
+ fetchTags: true,
299
+ fetchDepth: 0,
300
+ clean: true
301
+ - script: |
302
+ git config --global user.email "icg_it_linux@ingramcontent.com"
303
+ git config --global user.name "CICD"
304
+
305
+ SHA=$(git rev-parse HEAD)
306
+ working=working$SHA
307
+ git checkout -b $working
308
+ git branch -D $BRANCH &>/dev/null || true
309
+ git checkout $BRANCH
310
+ git branch -D $working &>/dev/null || true
311
+
312
+ git clean -dfX
313
+ displayName: Prepare
314
+
315
+ - task: DownloadPipelineArtifact@2
316
+ displayName: 'Restore Python Files'
317
+ inputs:
318
+ artifactName: 'python-lock-files'
319
+ targetPath: '$(System.DefaultWorkingDirectory)'
320
+
321
+ - script: |
322
+ npm run release:version
323
+ npm run release:finalize
324
+ npx enablement-build-version-repo --version --debug --save $VERSION
325
+ displayName: Version
326
+
327
+ - script: |
328
+ git commit -am "chore(release): $VERSION" -m "[skip ci]" --no-verify
329
+ git push
330
+ git push --tags
331
+ displayName: Success
332
+
333
+ - template: steps/deploy-ado-pipeline.yml@team
334
+ parameters:
335
+ pipelineName: $(PIPELINE_NAME)
336
+ branch: $(BRANCH)
@@ -0,0 +1,48 @@
1
+ {
2
+ "build.yml": {
3
+ "CONTAINERS": {
4
+ "rootPath": "../../containers",
5
+ "template": "build_docker_package.yml"
6
+ },
7
+ "CONTAINERS_CHECKMARX": {
8
+ "rootPath": "../../containers",
9
+ "template": "build_scan.yml"
10
+ },
11
+ "API": {
12
+ "rootPath": "../../api",
13
+ "template": "build_node_package.yml"
14
+ },
15
+ "PACKAGES": {
16
+ "rootPath": "../../packages",
17
+ "template": "build_node_component.yml"
18
+ },
19
+ "UX": {
20
+ "rootPath": "../../ux",
21
+ "template": "build_node_package.yml"
22
+ },
23
+ "CHECKMARX_UX": {
24
+ "rootPath": "../../ux",
25
+ "template": "build_scan_node.yml"
26
+ },
27
+ "CHECKMARX_API": {
28
+ "rootPath": "../../api",
29
+ "template": "build_scan_node.yml"
30
+ },
31
+ "PYTHON_PACKAGES": {
32
+ "rootPath":"../../python",
33
+ "template":"build_python_package.yml"
34
+ },
35
+ "PYTHON_CHECKMARX": {
36
+ "rootPath":"../../python",
37
+ "template":"build_scan.yml"
38
+ },
39
+ "DOTNET_PACKAGES": {
40
+ "rootPath":"../../dotnet",
41
+ "template":"build_dotnet_package.yml"
42
+ },
43
+ "DOTNET_CHECKMARX": {
44
+ "rootPath":"../../dotnet",
45
+ "template":"build_scan.yml"
46
+ }
47
+ }
48
+ }
package/index.mjs CHANGED
@@ -1,355 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from 'fs';
4
- import { globbySync } from 'globby';
5
- import YAML from 'yaml'
6
- import path from 'path';
3
+ import { runCli } from './src/cli.mjs';
7
4
 
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 = globbySync('*/**/package.json', {
17
- ignore: ['**/node_modules/**', '**/tests/**'],
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
- const folderName = path.basename(path.dirname(pkgPath));
48
- const packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
49
- if (pkgJson['.cicd']) {
50
- const cicdEntry = { ...pkgJson['.cicd'] };
51
- delete cicdEntry.templates;
52
- cicdEntry.folderName = folderName;
53
- cicdEntry.packageFolder = packageFolder;
54
- cicd[pkgJson.name] = cicdEntry;
55
- }
56
- // Always ensure folderName/packageFolder are present if cicd entry exists
57
- if (cicd[pkgJson.name]) {
58
- if (!cicd[pkgJson.name].folderName) cicd[pkgJson.name].folderName = folderName;
59
- if (!cicd[pkgJson.name].packageFolder) cicd[pkgJson.name].packageFolder = packageFolder;
60
- }
61
- }
62
- });
63
-
64
- if(options.debug)
65
- console.log(`Discovered ${Object.keys(nameToPath).length} local packages.`);
66
-
67
-
68
- // Second pass: build dependency tree
69
- Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
70
- const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
71
- const allDeps = {
72
- ...pkgJson.dependencies,
73
- ...pkgJson.devDependencies
74
- };
75
-
76
- const localDeps = Object.keys(allDeps || {}).filter(dep => nameToPath.hasOwnProperty(dep));
77
- dependencies[pkgName] = localDeps;
78
-
79
- // Populate reverse dependencies
80
- localDeps.forEach(dep => {
81
- if (!dependents[dep]) {
82
- dependents[dep] = [];
83
- }
84
- dependents[dep].push(pkgName);
85
- });
86
- });
87
-
88
- // now find all packages that do not have a .cicd entry
89
- Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
90
- if (!cicd[pkgName]) {
91
-
92
- var pkg = packageData[pkgPath];
93
- if (pkg['main'] || pkg['module']) {
94
- cicd[pkgName] = {
95
- output: "npm",
96
- language: 'JavaScript',
97
- tier: 'component'
98
- }
99
- if(pkg["bin"]) {
100
- cicd[pkgName]["packageType"] = "cli";
101
- cicd[pkgName]["tier"] = "tooling";
102
- }
103
- return;
104
- } else {
105
- if ((pkg['devDependencies'] && pkg['devDependencies']['moleculer']) || (pkg['dependencies'] && pkg['dependencies']['moleculer'])) {
106
- cicd[pkgName] = {
107
- "output": "npm",
108
- "language": "node",
109
- "packageType": "moleculer",
110
- "tier": "api"
111
- }
112
- return;
113
- } else if ((pkg['devDependencies'] && pkg['devDependencies']['react']) || (pkg['dependencies'] && pkg['dependencies']['react'])) {
114
- cicd[pkgName] = {
115
- "output": "npm",
116
- "language": "JavaScript",
117
- "packageType": "ux",
118
- "tier": "ux"
119
- }
120
- return;
121
- } else {
122
- // ok it is not node
123
- var folderName = path.dirname(pkgPath);
124
- const csproj = globbySync('*.csproj', { cwd: folderName, absolute: true });
125
- const toml = globbySync('pyproject.toml', { cwd: folderName, absolute: true });
126
- const docker = globbySync('Dockerfile', { cwd: folderName, absolute: true });
127
-
128
- if (csproj.length > 0) {
129
- cicd[pkgName] = {
130
- "output": "nuget",
131
- "language": "C#",
132
- "packageType": "dotnet",
133
- "tier": "api"
134
- }
135
- return;
136
- } else if (toml.length > 0) {
137
- cicd[pkgName] = {
138
- "output": "pypi",
139
- "language": "Python",
140
- "tier": "api"
141
- }
142
- if (pkgName.toLocaleLowerCase().endsWith("api")) cicd[pkgName]["packageType"] = "pypi";
143
- return;
144
- } else if (docker.length > 0 && (pkgPath.indexOf("image") >= 0 || pkgPath.indexOf("container") >= 0)) {
145
- cicd[pkgName] = {
146
- "output": "docker",
147
- "language": "Dockerfile",
148
- "tier": "infra"
149
- }
150
- return;
151
- }
152
- }
153
- }
154
- }
155
- });
156
-
157
- // Ensure folderName and packageFolder are set on every cicd entry
158
- Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
159
- if (cicd[pkgName]) {
160
- if (!cicd[pkgName].folderName)
161
- cicd[pkgName].folderName = path.basename(path.dirname(pkgPath));
162
- if (!cicd[pkgName].packageFolder)
163
- cicd[pkgName].packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
164
- }
165
- });
166
-
167
- // now find all packages where templates are missing
168
- // first load the templated.json
169
- let templatedConfig = {};
170
- if (fs.existsSync(`./${configDir}/templates/templated.json`)) {
171
- let data = JSON.parse(fs.readFileSync(`./${configDir}/templates/templated.json`, 'utf-8'));
172
- let innerNames ={};
173
- for (const outerKey in data) {
174
- const innerObj = data[outerKey];
175
- const innerKeys = Object.keys(innerObj);
176
- innerKeys.forEach(innerKey =>{
177
- let { rootPath, template } = innerObj[innerKey];
178
- rootPath = rootPath.replace(/\/+$/,''); // remove trailing slash
179
- rootPath = rootPath.replace(/\.\.\/+/g, ''); // remove leading ../
180
-
181
- innerNames[innerKey] = rootPath;
182
- templatedConfig[rootPath] = templatedConfig[rootPath] || [];
183
- templatedConfig[rootPath].push(template);
184
- });
185
- }
186
-
187
- for (const outerKey in data) {
188
- if(!pipelines[outerKey]) {
189
- let pipeline = pipelines[outerKey] = [];
190
-
191
- let tpl = fs.readFileSync(`./${configDir}/templates/${outerKey}`, 'utf-8');
192
-
193
- let i = 0;
194
- for (const innerKey of Object.keys(innerNames)) {
195
- const regex = new RegExp(`\{\s*${innerKey.toUpperCase()}\s*\}`, 'g');
196
- if(i==0) tpl = tpl.replace(regex, ` blocks:
197
- - ${innerKey.toUpperCase()}`);
198
- else tpl = tpl.replace(regex, ` - ${innerKey.toUpperCase()}`);
199
- i++;
200
- }
201
-
202
- var yaml = YAML.parse(tpl);
203
- for(const s of Object.keys(yaml.stages))
204
- {
205
- let stage = yaml.stages[s];
206
-
207
- let blocks = stage.blocks;
208
- if(blocks) {
209
- for(const b of blocks) {
210
- let pgkList= stage2Packages[innerNames[b]];
211
- if(pgkList) stage.packages = pgkList;
212
- }
213
- }
214
-
215
- delete stage.blocks;
216
- delete stage.jobs;
217
- delete stage.variables;
218
- pipeline.push(stage);
219
- }
220
- }
221
- }
222
- }
223
-
224
- const templates = globbySync(`./${configDir}/templates/*.yml`);
225
- const withUnderscore = templates
226
- .filter(file => file.includes('_'))
227
- .map(file => path.basename(file));
228
-
229
- const codeScan = withUnderscore.filter(file => file.includes('scan') || file.includes('check'));
230
- const build = withUnderscore.filter(file => !file.includes('scan') && !file.includes('check'));
231
-
232
- // Load existing manifest to preserve any already-set templated values
233
- const manifestPath = `./${configDir}/manifest.json`;
234
- const existingManifest = fs.existsSync(manifestPath)
235
- ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
236
- : {};
237
- const existingTemplated = existingManifest.templated || {};
238
- const existingPackages = (existingTemplated["build.yml"] || {})["PACKAGES"] || {};
239
- const existingCicd = existingManifest.cicd || {};
240
-
241
- const templated = {
242
- "build.yml": {
243
- "PACKAGES": {}
244
- }
245
- };
246
-
247
- Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
248
- if (cicd[pkgName]) {
249
- // Preserve any existing templated assignment for this package
250
- if (existingPackages[pkgName] !== undefined) {
251
- templated["build.yml"]["PACKAGES"][pkgName] = existingPackages[pkgName];
252
- return;
253
- }
254
- const pkgDir = path.basename(path.dirname(path.dirname(pkgPath)));
255
- if(templatedConfig[pkgDir]) {
256
- templated["build.yml"]["PACKAGES"][pkgName] = templatedConfig[pkgDir].length === 1
257
- ? templatedConfig[pkgDir][0]
258
- : templatedConfig[pkgDir];
259
- } else {
260
- let lookfor="node";
261
- if(cicd[pkgName]["output"]=="pypi") lookfor = "python";
262
- if(cicd[pkgName]["output"]=="nuget") lookfor = "dotnet";
263
- if(cicd[pkgName]["output"]=="docker") lookfor = "docker";
264
-
265
- let templates=[];
266
- build.forEach(file => {
267
- if (file.includes(lookfor) ) {
268
- templates.push(file);
269
- }
270
- });
271
- let found = false;
272
- codeScan.forEach(file => {
273
- if (file.includes(lookfor) ) {
274
- templates.push(file);
275
- found = true;
276
- }
277
- });
278
- if(!found && templates.length>0) templates.push("build_scan.yml");
279
- templated["build.yml"]["PACKAGES"][pkgName] = templates.length === 1 ? templates[0] : templates;
280
- }
281
- }
282
- });
283
-
284
- // Merge in any pre-existing cicd fields (hash, folderName, packageFolder set by release:finalize)
285
- Object.keys(existingCicd).forEach(pkgName => {
286
- if (cicd[pkgName]) {
287
- // Preserve fields from the existing entry that we don't regenerate
288
- if (existingCicd[pkgName].hash) cicd[pkgName].hash = existingCicd[pkgName].hash;
289
- if (existingCicd[pkgName].version) cicd[pkgName].version = existingCicd[pkgName].version;
290
- } else {
291
- // If package was in cicd before but not now, preserve entire entry
292
- cicd[pkgName] = existingCicd[pkgName];
293
- }
294
- });
295
-
296
- // Final output structure
297
- const output = {
298
- versions,
299
- dependencies,
300
- dependents,
301
- pipelines,
302
- templated,
303
- cicd
304
- };
305
-
306
- const outputJson = JSON.stringify(output, null, 2)
307
- if(options.save) fs.writeFileSync(`./${configDir}/manifest.json`, outputJson, 'utf-8');
308
- return outputJson;
309
- };
310
-
311
- let options = {
312
- debug:false,
313
- platform:null,
314
- hashExcludeFolders:['node_modules', 'coverage', 'dist'],
315
- hashExcludeFiles:['.npmrc','CHANGELOG.md','README.md']
316
- };
317
-
318
- // if (process.argv.length === 2) {
319
- // console.error('Expected at least one argument!');
320
- // process.exit(1);
321
- // } else {
322
- let argv = process.argv;
323
- for(let i=2;i<argv.length;i++) {
324
- if(argv[i]==="--save") options.save=true;
325
- else if(argv[i]==="--debug") options.debug=true;
326
- else if(argv[i]==="--changed") options.changed=true;
327
- else if(argv[i]==="--hash") options.hash=true;
328
- else if(argv[i]==="--platform") { options.platform = argv[i+1]; i++; }
329
- else if(argv[i]==="--hashExcludeFolders" || argv[i]==="--hashExcludeFiles") {
330
- let name = argv[i].substring(2);
331
- options[name] = argv[i+1].split(',');
332
- i++;
333
- } else
334
- if(argv[i].substring(0,2)==="--") {
335
- let name = argv[i].substring(2);
336
- if(options[name]!==undefined) {
337
- options[name] = argv[i+1];
338
- i++;
339
- } else {
340
- console.error(`Expected a known option, got ${argv[i]}`);
341
- process.exit(1);
342
- }
343
- }
344
- }
345
- //}
346
-
347
- (async () => {
348
- try {
349
- const text = await main(options);
350
- console.log(text);
351
- } catch (e) {
352
- console.log(e);
353
- process.exit(1);
354
- }
355
- })();
5
+ await runCli();
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "enablement-build-manifest",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "This builds a dependency tree.",
5
5
  "type": "module",
6
6
  "module": "./index.mjs",
7
+ "bin": "./index.mjs",
7
8
  "license": "MIT",
8
9
  "author": "Contributors",
9
10
  "dependencies": {
@@ -11,9 +12,7 @@
11
12
  "yaml": "^2.8.1"
12
13
  },
13
14
  "scripts": {
14
- "start": "node index.mjs"
15
- },
16
- "bin": {
17
- "enablement-build-manifest": "./index.mjs"
15
+ "start": "node index.mjs",
16
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest tests/*.test.mjs"
18
17
  }
19
18
  }
package/src/cli.mjs ADDED
@@ -0,0 +1,13 @@
1
+ import { generateManifest } from './manifest.mjs';
2
+ import { parseCliArgs } from './options.mjs';
3
+
4
+ export const runCli = async (argv = process.argv, log = console.log, exit = process.exit) => {
5
+ try {
6
+ const options = parseCliArgs(argv);
7
+ const text = await generateManifest(options);
8
+ log(text);
9
+ } catch (e) {
10
+ log(e);
11
+ exit(1);
12
+ }
13
+ };
@@ -0,0 +1,402 @@
1
+ import fs from 'fs';
2
+ import { globbySync } from 'globby';
3
+ import YAML from 'yaml'
4
+ import path from 'path';
5
+
6
+ export const generateManifest = async (options) => {
7
+
8
+ if (!options.platform) {
9
+ options.platform = (fs.existsSync('.github/templates/templated.json') || fs.existsSync('.github/manifest.json')) ? 'github' : 'ado';
10
+ }
11
+ const configDir = options.platform === 'github' ? '.github' : '.cicd';
12
+
13
+ // Find all package.json files, excluding node_modules
14
+ const packageJsonPaths = globbySync('*/**/package.json', {
15
+ ignore: ['**/node_modules/**', '**/tests/**'],
16
+ cwd: process.cwd(),
17
+ absolute: true
18
+ });
19
+
20
+ const nameToPath = {};
21
+
22
+ const versions = {};
23
+ const dependencies = {};
24
+ const dependents = {};
25
+ const cicd = {};
26
+ const pipelines = {};
27
+
28
+ const packageData = {};
29
+ const stage2Packages = {};
30
+
31
+ // First pass: collect all local package names and versions
32
+ packageJsonPaths.forEach(pkgPath => {
33
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
34
+ packageData[pkgPath] = pkgJson;
35
+ if (pkgJson.name && pkgJson.version) {
36
+ nameToPath[pkgJson.name] = pkgPath;
37
+
38
+ // create map of stage to packages
39
+ const stageName = path.basename(path.dirname(path.dirname(pkgPath)));
40
+ if(!stage2Packages[stageName]) stage2Packages[stageName] = [];
41
+ stage2Packages[stageName].push(pkgJson.name);
42
+
43
+ versions[pkgJson.name] = pkgJson.version;
44
+ dependents[pkgJson.name] = []; // Initialize reverse dependency map
45
+ const folderName = path.basename(path.dirname(pkgPath));
46
+ const packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
47
+ if (pkgJson['.cicd']) {
48
+ const cicdEntry = { ...pkgJson['.cicd'] };
49
+ delete cicdEntry.templates;
50
+ cicdEntry.folderName = folderName;
51
+ cicdEntry.packageFolder = packageFolder;
52
+ cicd[pkgJson.name] = cicdEntry;
53
+ }
54
+ // Always ensure folderName/packageFolder are present if cicd entry exists
55
+ if (cicd[pkgJson.name]) {
56
+ if (!cicd[pkgJson.name].folderName) cicd[pkgJson.name].folderName = folderName;
57
+ if (!cicd[pkgJson.name].packageFolder) cicd[pkgJson.name].packageFolder = packageFolder;
58
+ }
59
+ }
60
+ });
61
+
62
+ if(options.debug)
63
+ console.log(`Discovered ${Object.keys(nameToPath).length} local packages.`);
64
+
65
+
66
+ // Second pass: build dependency tree
67
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
68
+ const pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
69
+ const allDeps = {
70
+ ...pkgJson.dependencies,
71
+ ...pkgJson.devDependencies
72
+ };
73
+
74
+ const localDeps = Object.keys(allDeps || {}).filter(dep => nameToPath.hasOwnProperty(dep));
75
+ dependencies[pkgName] = localDeps;
76
+
77
+ // Populate reverse dependencies
78
+ localDeps.forEach(dep => {
79
+ if (!dependents[dep]) {
80
+ dependents[dep] = [];
81
+ }
82
+ dependents[dep].push(pkgName);
83
+ });
84
+ });
85
+
86
+ // now find all packages that do not have a .cicd entry
87
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
88
+ if (!cicd[pkgName]) {
89
+
90
+ var pkg = packageData[pkgPath];
91
+ if (pkg['main'] || pkg['module']) {
92
+ cicd[pkgName] = {
93
+ output: "npm",
94
+ language: 'JavaScript',
95
+ tier: 'component'
96
+ }
97
+ if(pkg["bin"]) {
98
+ cicd[pkgName]["packageType"] = "cli";
99
+ cicd[pkgName]["tier"] = "tooling";
100
+ }
101
+ return;
102
+ } else {
103
+ if ((pkg['devDependencies'] && pkg['devDependencies']['moleculer']) || (pkg['dependencies'] && pkg['dependencies']['moleculer'])) {
104
+ cicd[pkgName] = {
105
+ "output": "npm",
106
+ "language": "node",
107
+ "packageType": "moleculer",
108
+ "tier": "api"
109
+ }
110
+ return;
111
+ } else if ((pkg['devDependencies'] && pkg['devDependencies']['react']) || (pkg['dependencies'] && pkg['dependencies']['react'])) {
112
+ cicd[pkgName] = {
113
+ "output": "npm",
114
+ "language": "JavaScript",
115
+ "packageType": "ux",
116
+ "tier": "ux"
117
+ }
118
+ return;
119
+ } else {
120
+ // ok it is not node
121
+ var folderName = path.dirname(pkgPath);
122
+ const csproj = globbySync('*.csproj', { cwd: folderName, absolute: true });
123
+ const toml = globbySync('pyproject.toml', { cwd: folderName, absolute: true });
124
+ const docker = globbySync('Dockerfile', { cwd: folderName, absolute: true });
125
+
126
+ if (csproj.length > 0) {
127
+ cicd[pkgName] = {
128
+ "output": "nuget",
129
+ "language": "C#",
130
+ "packageType": "dotnet",
131
+ "tier": "api"
132
+ }
133
+ return;
134
+ } else if (toml.length > 0) {
135
+ cicd[pkgName] = {
136
+ "output": "pypi",
137
+ "language": "Python",
138
+ "tier": "api"
139
+ }
140
+ if (pkgName.toLocaleLowerCase().endsWith("api")) cicd[pkgName]["packageType"] = "pypi";
141
+ return;
142
+ } else if (docker.length > 0 && (pkgPath.indexOf("image") >= 0 || pkgPath.indexOf("container") >= 0)) {
143
+ cicd[pkgName] = {
144
+ "output": "docker",
145
+ "language": "Dockerfile",
146
+ "tier": "infra"
147
+ }
148
+ return;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ });
154
+
155
+ // Ensure folderName and packageFolder are set on every cicd entry
156
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
157
+ if (cicd[pkgName]) {
158
+ if (!cicd[pkgName].folderName)
159
+ cicd[pkgName].folderName = path.basename(path.dirname(pkgPath));
160
+ if (!cicd[pkgName].packageFolder)
161
+ cicd[pkgName].packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
162
+ }
163
+ });
164
+
165
+ // Build pipeline file list, placeholder map, and folder→entry map from templated.json.
166
+ // pipelineFiles: ["build.yml", ...]
167
+ // placeholderMap: { pipelineFile: { PLACEHOLDER: { template, folderName } } }
168
+ // folderToEntry: { folderName: { pipelineFile, placeholder, template } }
169
+ let pipelineFiles = [];
170
+ const placeholderMap = {};
171
+ const folderToEntry = {};
172
+ let templatedConfig = {}; // folderName → [templates], used by pipeline generation
173
+
174
+ const templatedJsonPath = `./${configDir}/templates/templated.json`;
175
+ if (fs.existsSync(templatedJsonPath)) {
176
+ let data = JSON.parse(fs.readFileSync(templatedJsonPath, 'utf-8'));
177
+ let innerNames = {};
178
+ for (const pipelineFile of Object.keys(data)) {
179
+ pipelineFiles.push(pipelineFile);
180
+ placeholderMap[pipelineFile] = {};
181
+ for (const [placeholder, entry] of Object.entries(data[pipelineFile])) {
182
+ let rootPath = entry.rootPath.replace(/\/+$/, '').replace(/\.\.\/+/g, '');
183
+ const tpl = entry.template;
184
+ placeholderMap[pipelineFile][placeholder] = { template: tpl, folderName: rootPath };
185
+ innerNames[placeholder] = rootPath;
186
+ templatedConfig[rootPath] = templatedConfig[rootPath] || [];
187
+ if (!templatedConfig[rootPath].includes(tpl)) templatedConfig[rootPath].push(tpl);
188
+ if (!folderToEntry[rootPath]) {
189
+ folderToEntry[rootPath] = { pipelineFile, placeholder, template: tpl };
190
+ }
191
+ }
192
+ }
193
+
194
+ for (const outerKey in data) {
195
+ if(!pipelines[outerKey]) {
196
+ let tpl = fs.readFileSync(`./${configDir}/templates/${outerKey}`, 'utf-8');
197
+
198
+ let i = 0;
199
+ for (const innerKey of Object.keys(innerNames)) {
200
+ const regex = new RegExp(`\{\s*${innerKey.toUpperCase()}\s*\}`, 'g');
201
+ if(i==0) tpl = tpl.replace(regex, ` blocks:\n - ${innerKey.toUpperCase()}`);
202
+ else tpl = tpl.replace(regex, ` - ${innerKey.toUpperCase()}`);
203
+ i++;
204
+ }
205
+
206
+ let yaml;
207
+ try {
208
+ yaml = YAML.parse(tpl);
209
+ } catch (e) {
210
+ if (options.debug) console.warn(`Skipping unparseable YAML template ${outerKey}: ${e.message}`);
211
+ continue;
212
+ }
213
+
214
+ let pipeline = pipelines[outerKey] = [];
215
+ for(const s of Object.keys(yaml.stages || {}))
216
+ {
217
+ let stage = yaml.stages[s];
218
+
219
+ let blocks = stage.blocks;
220
+ if(blocks) {
221
+ for(const b of blocks) {
222
+ let pgkList= stage2Packages[innerNames[b]];
223
+ if(pgkList) stage.packages = pgkList;
224
+ }
225
+ }
226
+
227
+ delete stage.blocks;
228
+ delete stage.jobs;
229
+ delete stage.variables;
230
+ pipeline.push(stage);
231
+ }
232
+ }
233
+ }
234
+ } else {
235
+ // Fallback: pipeline files = non-underscore .yml files in templates dir
236
+ const allTemplates = globbySync(`./${configDir}/templates/*.yml`);
237
+ pipelineFiles = allTemplates
238
+ .filter(f => !path.basename(f).includes('_'))
239
+ .map(f => path.basename(f));
240
+ // Placeholder keys = top-level children folder names uppercased
241
+ try {
242
+ const childrenFolders = fs.readdirSync('.').filter(f => {
243
+ try { return fs.statSync(f).isDirectory() && !f.startsWith('.') && f !== 'node_modules'; }
244
+ catch (e) { return false; }
245
+ });
246
+ for (const pipelineFile of pipelineFiles) {
247
+ placeholderMap[pipelineFile] = {};
248
+ for (const folder of childrenFolders) {
249
+ placeholderMap[pipelineFile][folder.toUpperCase()] = { template: null, folderName: folder };
250
+ if (!folderToEntry[folder]) {
251
+ folderToEntry[folder] = { pipelineFile, placeholder: folder.toUpperCase(), template: null };
252
+ }
253
+ }
254
+ }
255
+ } catch(e) {}
256
+ }
257
+
258
+ const templates = globbySync(`./${configDir}/templates/*.yml`);
259
+ const withUnderscore = templates
260
+ .filter(file => file.includes('_'))
261
+ .map(file => path.basename(file));
262
+
263
+ const codeScan = withUnderscore.filter(file => file.includes('scan') || file.includes('check'));
264
+ const build = withUnderscore.filter(file => !file.includes('scan') && !file.includes('check'));
265
+
266
+ // Load existing manifest to preserve any already-set templated values
267
+ const manifestPath = `./${configDir}/manifest.json`;
268
+ const existingManifest = fs.existsSync(manifestPath)
269
+ ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
270
+ : {};
271
+ const existingTemplated = existingManifest.templated || {};
272
+ const existingCicd = existingManifest.cicd || {};
273
+
274
+ // Helper: get package name from project.json → version.json → pyproject.toml
275
+ const getPackageNameFromFiles = (pkgDir) => {
276
+ const projectJsonPath = path.join(pkgDir, 'project.json');
277
+ if (fs.existsSync(projectJsonPath)) {
278
+ try { const d = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8')); if (d.name) return d.name; } catch(e) {}
279
+ }
280
+ const versionJsonPath = path.join(pkgDir, 'version.json');
281
+ if (fs.existsSync(versionJsonPath)) {
282
+ try { const d = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8')); if (d.name) return d.name; } catch(e) {}
283
+ }
284
+ const pyprojectPath = path.join(pkgDir, 'pyproject.toml');
285
+ if (fs.existsSync(pyprojectPath)) {
286
+ try {
287
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
288
+ const match = content.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
289
+ if (match) return match[1];
290
+ } catch(e) {}
291
+ }
292
+ return null;
293
+ };
294
+
295
+ // Helper: derive best-fit template from cicd output type using underscore-prefixed template files
296
+ const deriveTemplate = (pkgName) => {
297
+ let lookfor = "node";
298
+ if (cicd[pkgName]["output"] == "pypi") lookfor = "python";
299
+ if (cicd[pkgName]["output"] == "nuget") lookfor = "dotnet";
300
+ if (cicd[pkgName]["output"] == "docker") lookfor = "docker";
301
+ for (const file of build) {
302
+ if (file.includes(lookfor)) return file;
303
+ }
304
+ return build[0] || null;
305
+ };
306
+
307
+ // Helper: find existing template assignment for a package name in existing manifest
308
+ const getExistingTemplate = (pkgName) => {
309
+ for (const pf of Object.keys(existingTemplated)) {
310
+ for (const ph of Object.keys(existingTemplated[pf] || {})) {
311
+ const val = (existingTemplated[pf][ph] || {})[pkgName];
312
+ if (val !== undefined) return { pipelineFile: pf, placeholder: ph, tpl: val };
313
+ }
314
+ }
315
+ return null;
316
+ };
317
+
318
+ // Start from existing templated config so updates are additive-only.
319
+ const templated = JSON.parse(JSON.stringify(existingTemplated));
320
+
321
+ // Ensure discovered pipeline file and placeholder keys exist without removing prior keys.
322
+ if (pipelineFiles.length === 0) pipelineFiles = ["build.yml"];
323
+ for (const pipelineFile of pipelineFiles) {
324
+ if (!templated[pipelineFile]) templated[pipelineFile] = {};
325
+ for (const placeholder of Object.keys(placeholderMap[pipelineFile] || {})) {
326
+ if (!templated[pipelineFile][placeholder]) templated[pipelineFile][placeholder] = {};
327
+ }
328
+ if (Object.keys(templated[pipelineFile]).length === 0) {
329
+ templated[pipelineFile]["PACKAGES"] = {};
330
+ }
331
+ }
332
+
333
+ Object.entries(nameToPath).forEach(([pkgName, pkgPath]) => {
334
+ if (!cicd[pkgName]) return;
335
+
336
+ const pkgDir = path.dirname(pkgPath);
337
+ const folderName = path.basename(path.dirname(path.dirname(pkgPath)));
338
+
339
+ // Prefer package name from project.json/version.json/pyproject.toml
340
+ const preferredName = getPackageNameFromFiles(pkgDir) || pkgName;
341
+
342
+ // Find the pipeline file and placeholder for this package's folder
343
+ const entry = folderToEntry[folderName];
344
+ const pipelineFile = (entry && entry.pipelineFile) || pipelineFiles[0] || "build.yml";
345
+ let placeholder, template;
346
+
347
+ if (entry) {
348
+ placeholder = entry.placeholder;
349
+ template = entry.template;
350
+ } else {
351
+ // No folder mapping: pick best placeholder by output type
352
+ const lookfor = cicd[pkgName]["output"] == "pypi" ? "python" :
353
+ cicd[pkgName]["output"] == "nuget" ? "dotnet" :
354
+ cicd[pkgName]["output"] == "docker" ? "docker" : "node";
355
+ let bestPh = null, bestTpl = deriveTemplate(pkgName);
356
+ for (const [ph, info] of Object.entries(placeholderMap[pipelineFile] || {})) {
357
+ if (info.template && info.template.toLowerCase().includes(lookfor)) {
358
+ bestPh = ph;
359
+ bestTpl = info.template;
360
+ break;
361
+ }
362
+ }
363
+ placeholder = bestPh || "PACKAGES";
364
+ template = bestTpl;
365
+ }
366
+
367
+ if (!templated[pipelineFile]) templated[pipelineFile] = {};
368
+ if (!templated[pipelineFile][placeholder]) templated[pipelineFile][placeholder] = {};
369
+
370
+ // Additive-only behavior: keep any existing assignment and only add missing entries.
371
+ const existing = getExistingTemplate(preferredName) || getExistingTemplate(pkgName);
372
+ if (!existing) {
373
+ templated[pipelineFile][placeholder][preferredName] = template || deriveTemplate(pkgName);
374
+ }
375
+ });
376
+
377
+ // Merge in any pre-existing cicd fields (hash, folderName, packageFolder set by release:finalize)
378
+ Object.keys(existingCicd).forEach(pkgName => {
379
+ if (cicd[pkgName]) {
380
+ // Preserve fields from the existing entry that we don't regenerate
381
+ if (existingCicd[pkgName].hash) cicd[pkgName].hash = existingCicd[pkgName].hash;
382
+ if (existingCicd[pkgName].version) cicd[pkgName].version = existingCicd[pkgName].version;
383
+ } else {
384
+ // If package was in cicd before but not now, preserve entire entry
385
+ cicd[pkgName] = existingCicd[pkgName];
386
+ }
387
+ });
388
+
389
+ // Final output structure
390
+ const output = {
391
+ versions,
392
+ dependencies,
393
+ dependents,
394
+ pipelines,
395
+ templated,
396
+ cicd
397
+ };
398
+
399
+ const outputJson = JSON.stringify(output, null, 2)
400
+ if(options.save) fs.writeFileSync(`./${configDir}/manifest.json`, outputJson, 'utf-8');
401
+ return outputJson;
402
+ };
@@ -0,0 +1,33 @@
1
+ export const getDefaultOptions = () => ({
2
+ debug: false,
3
+ platform: null,
4
+ hashExcludeFolders: ['node_modules', 'coverage', 'dist'],
5
+ hashExcludeFiles: ['.npmrc', 'CHANGELOG.md', 'README.md']
6
+ });
7
+
8
+ export const parseCliArgs = (argv = process.argv) => {
9
+ const options = getDefaultOptions();
10
+ for (let i = 2; i < argv.length; i++) {
11
+ if (argv[i] === '--save') options.save = true;
12
+ else if (argv[i] === '--debug') options.debug = true;
13
+ else if (argv[i] === '--changed') options.changed = true;
14
+ else if (argv[i] === '--hash') options.hash = true;
15
+ else if (argv[i] === '--platform') {
16
+ options.platform = argv[i + 1];
17
+ i++;
18
+ } else if (argv[i] === '--hashExcludeFolders' || argv[i] === '--hashExcludeFiles') {
19
+ const name = argv[i].substring(2);
20
+ options[name] = argv[i + 1].split(',');
21
+ i++;
22
+ } else if (argv[i].substring(0, 2) === '--') {
23
+ const name = argv[i].substring(2);
24
+ if (options[name] !== undefined) {
25
+ options[name] = argv[i + 1];
26
+ i++;
27
+ } else {
28
+ throw new Error(`Expected a known option, got ${argv[i]}`);
29
+ }
30
+ }
31
+ }
32
+ return options;
33
+ };
@@ -0,0 +1,105 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { describe, it, expect } from '@jest/globals';
5
+ import { generateManifest } from '../src/manifest.mjs';
6
+
7
+ const writeJson = (filePath, obj) => {
8
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
9
+ fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf-8');
10
+ };
11
+
12
+ describe('generateManifest templated behavior', () => {
13
+ it('keeps existing templated mappings and adds missing packages only', async () => {
14
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'manifest-tests-'));
15
+ const oldCwd = process.cwd();
16
+
17
+ try {
18
+ writeJson(path.join(tmpRoot, 'packages/lib-a/package.json'), {
19
+ name: 'lib-a',
20
+ version: '1.0.0',
21
+ main: 'index.js'
22
+ });
23
+
24
+ writeJson(path.join(tmpRoot, 'packages/lib-b/package.json'), {
25
+ name: 'lib-b',
26
+ version: '1.0.0',
27
+ main: 'index.js'
28
+ });
29
+
30
+ fs.mkdirSync(path.join(tmpRoot, '.cicd/templates'), { recursive: true });
31
+ fs.writeFileSync(path.join(tmpRoot, '.cicd/templates/build.yml'), 'stages: []\n', 'utf-8');
32
+ fs.writeFileSync(path.join(tmpRoot, '.cicd/templates/_build_node_package.yml'), '# node build template\n', 'utf-8');
33
+
34
+ writeJson(path.join(tmpRoot, '.cicd/manifest.json'), {
35
+ templated: {
36
+ 'build.yml': {
37
+ PACKAGES: {
38
+ 'lib-a': 'custom_existing_template.yml'
39
+ }
40
+ },
41
+ 'legacy.yml': {
42
+ PACKAGES: {
43
+ 'legacy-pkg': 'legacy_template.yml'
44
+ }
45
+ }
46
+ }
47
+ });
48
+
49
+ process.chdir(tmpRoot);
50
+ const output = JSON.parse(await generateManifest({ platform: 'ado' }));
51
+
52
+ expect(output.templated['build.yml'].PACKAGES['lib-a']).toBe('custom_existing_template.yml');
53
+ expect(output.templated['build.yml'].PACKAGES['lib-b']).toBe('_build_node_package.yml');
54
+
55
+ expect(output.templated['legacy.yml']).toBeDefined();
56
+ expect(output.templated['legacy.yml'].PACKAGES['legacy-pkg']).toBe('legacy_template.yml');
57
+ } finally {
58
+ process.chdir(oldCwd);
59
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ it('ignores unparseable YAML templates and continues', async () => {
64
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'manifest-tests-'));
65
+ const oldCwd = process.cwd();
66
+
67
+ try {
68
+ writeJson(path.join(tmpRoot, 'packages/lib-a/package.json'), {
69
+ name: 'lib-a',
70
+ version: '1.0.0',
71
+ main: 'index.js'
72
+ });
73
+
74
+ fs.mkdirSync(path.join(tmpRoot, '.cicd/templates'), { recursive: true });
75
+ fs.writeFileSync(path.join(tmpRoot, '.cicd/templates/good.yml'), 'stages:\n build:\n name: Build\n', 'utf-8');
76
+ fs.writeFileSync(path.join(tmpRoot, '.cicd/templates/bad.yml'), 'stages:\n build: [\n', 'utf-8');
77
+ fs.writeFileSync(path.join(tmpRoot, '.cicd/templates/_build_node_package.yml'), '# node build template\n', 'utf-8');
78
+
79
+ writeJson(path.join(tmpRoot, '.cicd/templates/templated.json'), {
80
+ 'good.yml': {
81
+ PACKAGES: {
82
+ rootPath: '../packages',
83
+ template: 'build_node_package.yml'
84
+ }
85
+ },
86
+ 'bad.yml': {
87
+ PACKAGES: {
88
+ rootPath: '../packages',
89
+ template: 'build_node_package.yml'
90
+ }
91
+ }
92
+ });
93
+
94
+ process.chdir(tmpRoot);
95
+ const output = JSON.parse(await generateManifest({ platform: 'ado' }));
96
+
97
+ expect(output.pipelines['good.yml']).toBeDefined();
98
+ expect(Array.isArray(output.pipelines['good.yml'])).toBe(true);
99
+ expect(output.pipelines['bad.yml']).toBeUndefined();
100
+ } finally {
101
+ process.chdir(oldCwd);
102
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { getDefaultOptions, parseCliArgs } from '../src/options.mjs';
3
+
4
+ describe('manifest CLI options', () => {
5
+ it('returns expected defaults', () => {
6
+ expect(getDefaultOptions()).toEqual({
7
+ debug: false,
8
+ platform: null,
9
+ hashExcludeFolders: ['node_modules', 'coverage', 'dist'],
10
+ hashExcludeFiles: ['.npmrc', 'CHANGELOG.md', 'README.md']
11
+ });
12
+ });
13
+
14
+ it('parses boolean and valued flags', () => {
15
+ const options = parseCliArgs([
16
+ 'node',
17
+ 'index.mjs',
18
+ '--save',
19
+ '--debug',
20
+ '--platform',
21
+ 'github',
22
+ '--hashExcludeFolders',
23
+ 'node_modules,dist',
24
+ '--hashExcludeFiles',
25
+ 'README.md,CHANGELOG.md'
26
+ ]);
27
+
28
+ expect(options.save).toBe(true);
29
+ expect(options.debug).toBe(true);
30
+ expect(options.platform).toBe('github');
31
+ expect(options.hashExcludeFolders).toEqual(['node_modules', 'dist']);
32
+ expect(options.hashExcludeFiles).toEqual(['README.md', 'CHANGELOG.md']);
33
+ });
34
+
35
+ it('throws on unknown option', () => {
36
+ expect(() => parseCliArgs(['node', 'index.mjs', '--nope'])).toThrow(
37
+ 'Expected a known option, got --nope'
38
+ );
39
+ });
40
+ });