enablement-build-template-generate 1.0.10 → 1.0.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Chris Doty
3
+ Copyright (c) 2026 Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,4 +1,191 @@
1
- # enablement-build-deploy-version
2
- Gets package.json version and updates a deployment repo with the verison.
3
-
4
-
1
+ # enablement-build-template-generate
2
+
3
+ Generates pipeline YAML files for monorepos by expanding a job template once per package and stitching the results into a document template. Designed for Azure DevOps but works with any text-based pipeline format.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx enablement-build-template-generate@latest [flags]
9
+ ```
10
+
11
+ ---
12
+
13
+ ## How it works
14
+
15
+ 1. **Read config** — Loads a JSON config file, auto-detected from `.github/templates/templated.json` or `.cicd/templates/templated.json` based on the presence of a `.github/` directory.
16
+ 2. **Discover packages** — Globs for `package.json`, `version.json`, and `pyproject.toml` under the configured `rootPath`. When multiple manifest files exist in the same folder, the highest-priority one is used (`package.json` → `version.json` → `pyproject.toml`). Results are sorted alphabetically by folder name.
17
+ 3. **Expand job template** — For each discovered package, reads the package name from its manifest and fills in placeholders in the job snippet template.
18
+ 4. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
19
+ 5. **Write output** — Writes the final file one level above the template directory (`.github/templates/` → `.github/` or `.cicd/templates/` → `.cicd/`), wrapped in `DO NOT EDIT` banners.
20
+
21
+ ---
22
+
23
+ ## CLI flags
24
+
25
+ | Flag | Default | Description |
26
+ |---|---|---|
27
+ | `--config <path>` | auto-detect | Path to the JSON config file. Defaults to `.github/templates/templated.json` if a `.github/` directory exists, otherwise `.cicd/templates/templated.json`. Resolved relative to the package first, then relative to the working directory. |
28
+ | `--platform <name>` | auto-detect | Force the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). Auto-detected from the presence of a `.github/` directory at the working directory root. |
29
+ | `--exclude <list>` | — | Comma- or space-separated list of package folder names to skip during generation. |
30
+ | `--debug` | off | Print verbose output. |
31
+
32
+ ---
33
+
34
+ ## Config file format
35
+
36
+ The config file is a JSON object. Each key is the **output filename** (relative to the directory above the template folder). Each value maps placeholder variable names to job configurations.
37
+
38
+ ```json
39
+ {
40
+ "ci-build.yaml": {
41
+ "jobs": {
42
+ "template": "job-template.yaml",
43
+ "rootPath": "./packages",
44
+ "exclude": ["internal-tool"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ | Field | Description |
51
+ |---|---|
52
+ | `template` | Path to the job snippet template, relative to the config file's directory. |
53
+ | `rootPath` | Root directory to scan for `package.json` files, relative to the config file's directory. |
54
+ | `exclude` | _(optional)_ Array of folder names to skip. |
55
+
56
+ Multiple output files and multiple variables per output file are supported:
57
+
58
+ ```json
59
+ {
60
+ "ci-components.yaml": {
61
+ "componentJobs": {
62
+ "template": "component-job.yaml",
63
+ "rootPath": "./components"
64
+ }
65
+ },
66
+ "ci-saas.yaml": {
67
+ "saasJobs": {
68
+ "template": "saas-job.yaml",
69
+ "rootPath": "./saas",
70
+ "exclude": ["saas-legacy"]
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Template placeholders
79
+
80
+ ### Document template
81
+
82
+ The document template (the top-level YAML file) uses `{variableName}` placeholders that are replaced with the fully expanded set of job snippets:
83
+
84
+ ```yaml
85
+ # ci-build.yaml (document template)
86
+ stages:
87
+ - stage: Build
88
+ jobs:
89
+ {jobs}
90
+ ```
91
+
92
+ ### Supported manifest files
93
+
94
+ Each subfolder is scanned for a version manifest in this priority order:
95
+
96
+ | Priority | File | Format |
97
+ |---|---|---|
98
+ | 1 | `package.json` | JSON — `"name"` field |
99
+ | 2 | `version.json` | JSON — same shape as `package.json` |
100
+ | 3 | `pyproject.toml` | TOML — `name` field under `[project]` or `[tool.poetry]` |
101
+
102
+ When multiple manifest files exist in the same folder the highest-priority one is used. If none contains a `name` field the folder name is used as a fallback.
103
+
104
+ ### Job template
105
+
106
+ Each job snippet template is expanded once per package. The following placeholders are available:
107
+
108
+ | Placeholder | Value |
109
+ |---|---|
110
+ | `{name}` | Package folder name (e.g. `my-component`) |
111
+ | `{safe}` | Folder name with `-`, `_`, and `.` removed — safe for use as a YAML identifier (e.g. `mycomponent`) |
112
+ | `{package}` | npm package name without scope prefix (e.g. `my-component` from `@scope/my-component`) |
113
+ | `{full}` | Full npm package name including scope (e.g. `@scope/my-component`) |
114
+ | `{path}` | The `rootPath` value with any leading `./` stripped |
115
+ | `{other}` | Expands to a comma-separated list of paths to every **other** package in the same scan — useful for pipeline dependency declarations |
116
+
117
+ Example job template:
118
+
119
+ ```yaml
120
+ - job: build_{safe}
121
+ displayName: Build {name}
122
+ steps:
123
+ - script: pnpm --filter {full} build
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Directory layout
129
+
130
+ Templates and config live inside a subfolder; generated files are written one level up.
131
+
132
+ **Azure DevOps** (default when no `.github/` directory):
133
+ ```
134
+ .cicd/
135
+ ├── ci-build.yaml ← generated output
136
+ └── templates/
137
+ ├── templated.json ← config file
138
+ ├── document.yaml ← document template
139
+ └── job.yaml ← job snippet template
140
+ ```
141
+
142
+ **GitHub Actions** (when `.github/` directory is present):
143
+ ```
144
+ .github/
145
+ ├── ci-build.yaml ← generated output
146
+ └── templates/
147
+ ├── templated.json ← config file
148
+ ├── document.yaml ← document template
149
+ └── job.yaml ← job snippet template
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Typical pipeline usage
155
+
156
+ Run the generator as a pipeline step before the build stage:
157
+
158
+ ```yaml
159
+ - script: npx enablement-build-template-generate@latest
160
+ displayName: Generate pipeline YAML
161
+ ```
162
+
163
+ With a custom config location:
164
+
165
+ ```bash
166
+ npx enablement-build-template-generate@latest --config .azuredevops/templates/templated.json
167
+ ```
168
+
169
+ Excluding specific packages:
170
+
171
+ ```bash
172
+ npx enablement-build-template-generate@latest --exclude legacy-pkg,test-harness
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Output format
178
+
179
+ Generated files are wrapped in `DO NOT EDIT` banners. Edit the templates, not the output:
180
+
181
+ ```yaml
182
+ #######################################
183
+ ### DO NOT EDIT - THIS IS GENERATED ###
184
+ #######################################
185
+
186
+ # ... generated content ...
187
+
188
+ #######################################
189
+ ### DO NOT EDIT - THIS IS GENERATED ###
190
+ #######################################
191
+ ```
package/package.json CHANGED
@@ -1,17 +1,28 @@
1
1
  {
2
2
  "name": "enablement-build-template-generate",
3
- "version": "1.0.10",
3
+ "version": "1.0.13",
4
4
  "description": "Generates build yaml for mono repos",
5
5
  "type": "module",
6
- "module": "./index.js",
7
- "main": "./index.js",
8
- "bin": "./index.js",
6
+ "module": "./src/index.mjs",
7
+ "main": "./src/index.mjs",
8
+ "bin": "./src/index.mjs",
9
9
  "dependencies": {
10
10
  "globby": "^14.0.1"
11
11
  },
12
- "scripts": {
13
- "start": "node index.js"
12
+ "devDependencies": {
13
+ "jest": "^29.0.0"
14
+ },
15
+ "jest": {
16
+ "testEnvironment": "node",
17
+ "testMatch": [
18
+ "**/*.test.mjs"
19
+ ]
14
20
  },
15
21
  "license": "MIT",
16
- "author": "Chris Doty"
22
+ "author": "Contributors",
23
+ "scripts": {
24
+ "start": "node src/index.mjs",
25
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
26
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
27
+ }
17
28
  }
package/src/index.mjs ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import * as url from 'url';
3
+ import path from 'path';
4
+ import { globby } from 'globby';
5
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
6
+
7
+ import {
8
+ stripWhitespace,
9
+ parseManifestName,
10
+ buildSearchGlobs,
11
+ groupByFolder,
12
+ resolvePackageName,
13
+ makeSafe,
14
+ expandJobTemplate,
15
+ resolveOtherPlaceholders,
16
+ applyVarsToDocTemplate,
17
+ wrapWithBanner,
18
+ } from './lib.mjs';
19
+
20
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
21
+
22
+ function readManifestName(filePath) {
23
+ const content = readFileSync(filePath, 'utf8');
24
+ const fileType = filePath.endsWith('.toml') ? 'toml' : 'json';
25
+ return parseManifestName(content, fileType);
26
+ }
27
+
28
+ async function main(options) {
29
+ let configFile = path.join(__dirname, options.config);
30
+ const try1 = configFile;
31
+
32
+ if (!existsSync(configFile)) {
33
+ configFile = path.join(options.config);
34
+ }
35
+
36
+ if (!existsSync(configFile)) {
37
+ throw `enablement-build-template-generate: could not load config file ${configFile}, also tried ${try1}`;
38
+ }
39
+
40
+ const templatePath = path.dirname(configFile);
41
+ const config = JSON.parse(readFileSync(configFile, 'utf8'));
42
+ const search = buildSearchGlobs(options.exclude);
43
+ const plist = [];
44
+
45
+ Object.keys(config).forEach((templateFilename) => {
46
+ plist.push(new Promise(async (primaryResolve, reject) => {
47
+ const docTemplate = readFileSync(path.join(templatePath, templateFilename), 'utf8');
48
+ const jlist = [];
49
+
50
+ Object.keys(config[templateFilename]).forEach((varname) => {
51
+ const jobConfig = config[templateFilename][varname];
52
+ const job = new Promise(async (resolve, reject) => {
53
+ const jobTemplate = readFileSync(path.join(templatePath, jobConfig.template), 'utf8');
54
+ const cwd = path.join(templatePath, jobConfig.rootPath);
55
+ const jobRoot = jobConfig.rootPath.replace(/^[\.\/\\]+/, '');
56
+ const exclude = jobConfig.exclude || [];
57
+
58
+ globby(search, { cwd }).then((images) => {
59
+ images.sort();
60
+ const folderBest = groupByFolder(images);
61
+ const allNames = Object.keys(folderBest).sort()
62
+ .filter(name => !name.startsWith('_') && !exclude.includes(name));
63
+
64
+ const expandedJobs = allNames.map(name => {
65
+ const fullName = readManifestName(path.join(cwd, folderBest[name].filePath)) || name;
66
+ const packageName = resolvePackageName(fullName);
67
+ const safe = makeSafe(name);
68
+ return expandJobTemplate(jobTemplate, { name, safe, packageName, fullName, jobRoot });
69
+ });
70
+
71
+ let jobs = resolveOtherPlaceholders(expandedJobs.join('\n'), allNames, cwd);
72
+ resolve({ name: varname, text: jobs });
73
+ }, reject);
74
+ });
75
+ jlist.push(job);
76
+ });
77
+
78
+ Promise.all(jlist).then((results) => {
79
+ const output = applyVarsToDocTemplate(docTemplate, results);
80
+ const wrapped = wrapWithBanner(output);
81
+
82
+ if (stripWhitespace(wrapped) !== docTemplate) {
83
+ writeFileSync(path.join(templatePath, `../${templateFilename}`), wrapped, 'utf8');
84
+ }
85
+
86
+ primaryResolve(`generated template ${templateFilename}`);
87
+ }, reject);
88
+ }));
89
+ });
90
+
91
+ return Promise.all(plist);
92
+ }
93
+
94
+ let options = {
95
+ config: null,
96
+ platform: null,
97
+ exclude: [],
98
+ debug: false,
99
+ };
100
+
101
+ let argv = process.argv;
102
+
103
+ for (let i = 2; i < argv.length; i++) {
104
+ if (argv[i] === '--debug') { options.debug = true; continue; }
105
+ if (argv[i].substring(0, 2) === '--') {
106
+ const name = argv[i].substring(2);
107
+ if (name === 'exclude') {
108
+ options[name] = argv[i + 1].replace(/\s/g, ',').split(',');
109
+ i++;
110
+ } else if (options[name] !== undefined) {
111
+ options[name] = argv[i + 1];
112
+ i++;
113
+ } else {
114
+ console.error('Expected a known option');
115
+ process.exit(1);
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!options.platform) {
121
+ options.platform = existsSync(path.join(process.cwd(), '.github')) ? 'github' : 'ado';
122
+ }
123
+ if (!options.config) {
124
+ options.config = options.platform === 'github'
125
+ ? '.github/templates/templated.json'
126
+ : '.cicd/templates/templated.json';
127
+ }
128
+
129
+ (async () => {
130
+ try {
131
+ console.log('\x1b[32m%s\x1b[0m', 'Running enablement-build-template-generate with options:\n', JSON.stringify(options, null, 2));
132
+ const results = await main(options);
133
+ console.log(results.join('\n'));
134
+ if (options.debug) console.log('DONE');
135
+ process.exit(0);
136
+ } catch (e) {
137
+ console.log('\x1b[32m%s\x1b[0m', e);
138
+ if (options.debug) console.log('ERROR');
139
+ process.exit(1);
140
+ }
141
+ })();
package/src/lib.mjs ADDED
@@ -0,0 +1,111 @@
1
+ import path from 'path';
2
+
3
+ // Priority order for version manifest files (lower index = higher priority)
4
+ export const MANIFEST_PRIORITY = { 'package.json': 0, 'version.json': 1, 'pyproject.toml': 2 };
5
+
6
+ // Strip all whitespace characters from a string
7
+ export function stripWhitespace(str) {
8
+ return str.replace(/( |\r\n|\n|\r)/gm, '');
9
+ }
10
+
11
+ // Read the package name from pyproject.toml content (supports [project] and [tool.poetry])
12
+ export function readTomlName(content) {
13
+ const lines = content.split('\n');
14
+ let inSection = false;
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#')) continue;
18
+ if (/^\[project\]$/.test(trimmed) || /^\[tool\.poetry\]$/.test(trimmed)) { inSection = true; continue; }
19
+ if (/^\[/.test(trimmed)) { inSection = false; continue; }
20
+ if (inSection) {
21
+ const m = trimmed.match(/^name\s*=\s*["']([^"']+)["']/);
22
+ if (m) return m[1];
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ // Parse a package name from manifest file content.
29
+ // fileType must be 'toml' or 'json' (covers package.json and version.json).
30
+ export function parseManifestName(content, fileType) {
31
+ if (fileType === 'toml') return readTomlName(content);
32
+ try { return JSON.parse(content).name || null; } catch { return null; }
33
+ }
34
+
35
+ // Build the globby search array from an exclude list of folder names.
36
+ export function buildSearchGlobs(excludeList) {
37
+ const search = ['./**/package.json', './**/version.json', './**/pyproject.toml'];
38
+ for (const ex of excludeList) {
39
+ search.push(`!**/${ex}`);
40
+ }
41
+ search.push('!**/node_modules');
42
+ return search;
43
+ }
44
+
45
+ // Group globby-returned file paths by folder, keeping the highest-priority manifest per folder.
46
+ // Input paths are relative to the cwd passed to globby.
47
+ export function groupByFolder(filePaths) {
48
+ const folderBest = {};
49
+ for (const filePath of filePaths) {
50
+ const filename = path.basename(filePath);
51
+ const folder = filePath.slice(0, filePath.length - filename.length - 1);
52
+ const priority = MANIFEST_PRIORITY[filename] ?? 99;
53
+ if (!folderBest[folder] || priority < folderBest[folder].priority) {
54
+ folderBest[folder] = { filePath, priority };
55
+ }
56
+ }
57
+ return folderBest;
58
+ }
59
+
60
+ // Strip the npm scope prefix from a package name (e.g. '@scope/foo' → 'foo').
61
+ export function resolvePackageName(fullName) {
62
+ if (fullName.startsWith('@')) return fullName.split('/')[1];
63
+ return fullName;
64
+ }
65
+
66
+ // Return a name safe for use as a YAML identifier by removing -, _, and . characters.
67
+ export function makeSafe(name) {
68
+ return name.replace(/-/g, '').replace(/_/g, '').replace(/\./g, '');
69
+ }
70
+
71
+ // Expand a job template string for one package, substituting all known placeholders.
72
+ // {other} is replaced with a tagged sentinel {other:name} for later resolution.
73
+ export function expandJobTemplate(template, { name, safe, packageName, fullName, jobRoot }) {
74
+ return template
75
+ .replace(/{other}/g, `{other:${name}}`)
76
+ .replace(/{path}/g, jobRoot)
77
+ .replace(/{name}/g, name)
78
+ .replace(/{safe}/g, safe)
79
+ .replace(/{package}/g, packageName)
80
+ .replace(/{full}/g, fullName);
81
+ }
82
+
83
+ // Resolve all {other:name} sentinels in the combined jobs string.
84
+ // Each sentinel is replaced with a comma-separated list of absolute paths to every OTHER package.
85
+ export function resolveOtherPlaceholders(jobs, allNames, cwd) {
86
+ for (let i = 0; i < allNames.length; i++) {
87
+ const cnames = allNames.slice();
88
+ cnames.splice(i, 1);
89
+ const cnamesStr = cnames.join(`, ${cwd}/`);
90
+ const rex = new RegExp(`\\{other:${allNames[i]}\\}`, 'g');
91
+ jobs = jobs.replace(rex, `${cwd}/${cnamesStr}`);
92
+ }
93
+ return jobs;
94
+ }
95
+
96
+ // Substitute variable results into a document template, replacing {varName} placeholders.
97
+ export function applyVarsToDocTemplate(docTemplate, varResults) {
98
+ let output = docTemplate;
99
+ for (const { name, text } of varResults) {
100
+ const rex = new RegExp(`\\{${name}\\}`, 'g');
101
+ output = output.replace(rex, text);
102
+ }
103
+ return output;
104
+ }
105
+
106
+ // Wrap content in DO NOT EDIT banners.
107
+ export function wrapWithBanner(content) {
108
+ const line = '#######################################';
109
+ const donotedit = '### DO NOT EDIT - THIS IS GENERATED ###';
110
+ return `${line}\n${donotedit}\n${line}\n\n${content}\n\n${line}\n${donotedit}\n${line}`;
111
+ }
@@ -0,0 +1,356 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import {
3
+ MANIFEST_PRIORITY,
4
+ stripWhitespace,
5
+ readTomlName,
6
+ parseManifestName,
7
+ buildSearchGlobs,
8
+ groupByFolder,
9
+ resolvePackageName,
10
+ makeSafe,
11
+ expandJobTemplate,
12
+ resolveOtherPlaceholders,
13
+ applyVarsToDocTemplate,
14
+ wrapWithBanner,
15
+ } from '../src/lib.mjs';
16
+
17
+ // ─── MANIFEST_PRIORITY ────────────────────────────────────────────────────────
18
+
19
+ describe('MANIFEST_PRIORITY', () => {
20
+ it('gives package.json the highest priority (0)', () => {
21
+ expect(MANIFEST_PRIORITY['package.json']).toBe(0);
22
+ });
23
+ it('gives version.json second priority (1)', () => {
24
+ expect(MANIFEST_PRIORITY['version.json']).toBe(1);
25
+ });
26
+ it('gives pyproject.toml the lowest defined priority (2)', () => {
27
+ expect(MANIFEST_PRIORITY['pyproject.toml']).toBe(2);
28
+ });
29
+ });
30
+
31
+ // ─── stripWhitespace ─────────────────────────────────────────────────────────
32
+
33
+ describe('stripWhitespace', () => {
34
+ it('removes spaces', () => {
35
+ expect(stripWhitespace('a b c')).toBe('abc');
36
+ });
37
+ it('removes newlines', () => {
38
+ expect(stripWhitespace('a\nb\nc')).toBe('abc');
39
+ });
40
+ it('removes carriage returns', () => {
41
+ expect(stripWhitespace('a\r\nb')).toBe('ab');
42
+ });
43
+ it('leaves non-whitespace characters intact', () => {
44
+ expect(stripWhitespace('hello:world')).toBe('hello:world');
45
+ });
46
+ });
47
+
48
+ // ─── readTomlName ─────────────────────────────────────────────────────────────
49
+
50
+ describe('readTomlName', () => {
51
+ it('reads name from [project] section', () => {
52
+ const content = '[project]\nname = "my-tool"\nversion = "1.0.0"\n';
53
+ expect(readTomlName(content)).toBe('my-tool');
54
+ });
55
+
56
+ it('reads name from [tool.poetry] section', () => {
57
+ const content = '[tool.poetry]\nname = "my-poetry-pkg"\nversion = "0.1.0"\n';
58
+ expect(readTomlName(content)).toBe('my-poetry-pkg');
59
+ });
60
+
61
+ it('accepts single-quoted names', () => {
62
+ const content = "[project]\nname = 'single-quoted'\n";
63
+ expect(readTomlName(content)).toBe('single-quoted');
64
+ });
65
+
66
+ it('ignores name outside recognized sections', () => {
67
+ const content = '[build-system]\nname = "ignored"\n';
68
+ expect(readTomlName(content)).toBeNull();
69
+ });
70
+
71
+ it('stops reading at the next section header', () => {
72
+ const content = '[project]\nname = "correct"\n[other]\nname = "wrong"\n';
73
+ expect(readTomlName(content)).toBe('correct');
74
+ });
75
+
76
+ it('skips blank lines and comments', () => {
77
+ const content = '[project]\n# a comment\n\nname = "pkg"\n';
78
+ expect(readTomlName(content)).toBe('pkg');
79
+ });
80
+
81
+ it('returns null when no name field exists in a known section', () => {
82
+ const content = '[project]\nversion = "1.0.0"\n';
83
+ expect(readTomlName(content)).toBeNull();
84
+ });
85
+
86
+ it('returns null for completely empty content', () => {
87
+ expect(readTomlName('')).toBeNull();
88
+ });
89
+ });
90
+
91
+ // ─── parseManifestName ────────────────────────────────────────────────────────
92
+
93
+ describe('parseManifestName', () => {
94
+ it('parses name from JSON content', () => {
95
+ expect(parseManifestName('{"name":"my-pkg","version":"1.0.0"}', 'json')).toBe('my-pkg');
96
+ });
97
+
98
+ it('returns null for JSON without a name field', () => {
99
+ expect(parseManifestName('{"version":"1.0.0"}', 'json')).toBeNull();
100
+ });
101
+
102
+ it('parses name from TOML content', () => {
103
+ expect(parseManifestName('[project]\nname = "my-tool"\n', 'toml')).toBe('my-tool');
104
+ });
105
+
106
+ it('returns null for invalid JSON', () => {
107
+ expect(parseManifestName('not valid json', 'json')).toBeNull();
108
+ });
109
+
110
+ it('returns null for TOML with no recognized section', () => {
111
+ expect(parseManifestName('[build-system]\nname = "x"\n', 'toml')).toBeNull();
112
+ });
113
+ });
114
+
115
+ // ─── buildSearchGlobs ─────────────────────────────────────────────────────────
116
+
117
+ describe('buildSearchGlobs', () => {
118
+ it('includes all three manifest patterns', () => {
119
+ const globs = buildSearchGlobs([]);
120
+ expect(globs).toContain('./**/package.json');
121
+ expect(globs).toContain('./**/version.json');
122
+ expect(globs).toContain('./**/pyproject.toml');
123
+ });
124
+
125
+ it('always excludes node_modules', () => {
126
+ expect(buildSearchGlobs([])).toContain('!**/node_modules');
127
+ });
128
+
129
+ it('adds negation globs for each excluded folder', () => {
130
+ const globs = buildSearchGlobs(['legacy', 'temp']);
131
+ expect(globs).toContain('!**/legacy');
132
+ expect(globs).toContain('!**/temp');
133
+ });
134
+
135
+ it('places exclude entries after the include patterns', () => {
136
+ const globs = buildSearchGlobs(['skip-me']);
137
+ const firstInclude = globs.indexOf('./**/package.json');
138
+ const excludeIdx = globs.indexOf('!**/skip-me');
139
+ expect(firstInclude).toBeLessThan(excludeIdx);
140
+ });
141
+
142
+ it('returns only include + node_modules exclusion when exclude list is empty', () => {
143
+ const globs = buildSearchGlobs([]);
144
+ expect(globs.filter(g => g.startsWith('!') && g !== '!**/node_modules')).toHaveLength(0);
145
+ });
146
+ });
147
+
148
+ // ─── groupByFolder ────────────────────────────────────────────────────────────
149
+
150
+ describe('groupByFolder', () => {
151
+ it('returns an empty object for empty input', () => {
152
+ expect(groupByFolder([])).toEqual({});
153
+ });
154
+
155
+ it('groups a single manifest file correctly', () => {
156
+ const result = groupByFolder(['pkg-a/package.json']);
157
+ expect(result['pkg-a'].filePath).toBe('pkg-a/package.json');
158
+ });
159
+
160
+ it('picks package.json over version.json (higher priority)', () => {
161
+ const result = groupByFolder(['pkg-a/version.json', 'pkg-a/package.json']);
162
+ expect(result['pkg-a'].filePath).toBe('pkg-a/package.json');
163
+ });
164
+
165
+ it('picks package.json over pyproject.toml', () => {
166
+ const result = groupByFolder(['pkg-a/pyproject.toml', 'pkg-a/package.json']);
167
+ expect(result['pkg-a'].filePath).toBe('pkg-a/package.json');
168
+ });
169
+
170
+ it('picks version.json over pyproject.toml', () => {
171
+ const result = groupByFolder(['pkg-b/pyproject.toml', 'pkg-b/version.json']);
172
+ expect(result['pkg-b'].filePath).toBe('pkg-b/version.json');
173
+ });
174
+
175
+ it('keeps each folder as a separate key', () => {
176
+ const files = ['pkg-a/package.json', 'pkg-b/package.json', 'pkg-c/pyproject.toml'];
177
+ const result = groupByFolder(files);
178
+ expect(Object.keys(result).sort()).toEqual(['pkg-a', 'pkg-b', 'pkg-c']);
179
+ });
180
+
181
+ it('stores the priority value alongside the filePath', () => {
182
+ const result = groupByFolder(['pkg-a/version.json']);
183
+ expect(result['pkg-a'].priority).toBe(1);
184
+ });
185
+ });
186
+
187
+ // ─── resolvePackageName ───────────────────────────────────────────────────────
188
+
189
+ describe('resolvePackageName', () => {
190
+ it('strips the scope prefix from a scoped package name', () => {
191
+ expect(resolvePackageName('@acme/my-lib')).toBe('my-lib');
192
+ });
193
+
194
+ it('returns unscoped names unchanged', () => {
195
+ expect(resolvePackageName('my-lib')).toBe('my-lib');
196
+ });
197
+
198
+ it('works with arbitrary scope names', () => {
199
+ expect(resolvePackageName('@org/pkg-name')).toBe('pkg-name');
200
+ });
201
+ });
202
+
203
+ // ─── makeSafe ─────────────────────────────────────────────────────────────────
204
+
205
+ describe('makeSafe', () => {
206
+ it('removes dashes', () => {
207
+ expect(makeSafe('my-component')).toBe('mycomponent');
208
+ });
209
+
210
+ it('removes underscores', () => {
211
+ expect(makeSafe('my_lib')).toBe('mylib');
212
+ });
213
+
214
+ it('removes dots', () => {
215
+ expect(makeSafe('my.tool')).toBe('mytool');
216
+ });
217
+
218
+ it('removes all three special characters together', () => {
219
+ expect(makeSafe('my-lib_v1.0')).toBe('mylibv10');
220
+ });
221
+
222
+ it('leaves alphanumeric characters untouched', () => {
223
+ expect(makeSafe('mylib123')).toBe('mylib123');
224
+ });
225
+ });
226
+
227
+ // ─── expandJobTemplate ────────────────────────────────────────────────────────
228
+
229
+ describe('expandJobTemplate', () => {
230
+ const params = {
231
+ name: 'my-lib',
232
+ safe: 'mylib',
233
+ packageName: 'my-lib',
234
+ fullName: '@acme/my-lib',
235
+ jobRoot: 'packages',
236
+ };
237
+
238
+ it('replaces {name}', () => {
239
+ expect(expandJobTemplate('{name}', params)).toBe('my-lib');
240
+ });
241
+
242
+ it('replaces {safe}', () => {
243
+ expect(expandJobTemplate('{safe}', params)).toBe('mylib');
244
+ });
245
+
246
+ it('replaces {package}', () => {
247
+ expect(expandJobTemplate('{package}', params)).toBe('my-lib');
248
+ });
249
+
250
+ it('replaces {full}', () => {
251
+ expect(expandJobTemplate('{full}', params)).toBe('@acme/my-lib');
252
+ });
253
+
254
+ it('replaces {path}', () => {
255
+ expect(expandJobTemplate('{path}', params)).toBe('packages');
256
+ });
257
+
258
+ it('converts {other} to a tagged sentinel for later resolution', () => {
259
+ expect(expandJobTemplate('{other}', params)).toBe('{other:my-lib}');
260
+ });
261
+
262
+ it('replaces multiple occurrences of the same placeholder', () => {
263
+ expect(expandJobTemplate('{name} {name}', params)).toBe('my-lib my-lib');
264
+ });
265
+
266
+ it('replaces all placeholders in a realistic template', () => {
267
+ const tmpl = '- job: build_{safe}\n displayName: Build {name}\n steps:\n - script: pnpm --filter {full} build';
268
+ const result = expandJobTemplate(tmpl, params);
269
+ expect(result).toBe('- job: build_mylib\n displayName: Build my-lib\n steps:\n - script: pnpm --filter @acme/my-lib build');
270
+ });
271
+ });
272
+
273
+ // ─── resolveOtherPlaceholders ─────────────────────────────────────────────────
274
+
275
+ describe('resolveOtherPlaceholders', () => {
276
+ it('replaces {other:name} with the absolute paths of all other packages', () => {
277
+ const jobs = 'a:{other:pkg-a}\nb:{other:pkg-b}';
278
+ const result = resolveOtherPlaceholders(jobs, ['pkg-a', 'pkg-b'], '/root');
279
+ expect(result).toBe('a:/root/pkg-b\nb:/root/pkg-a');
280
+ });
281
+
282
+ it('lists all other packages in a three-package scenario', () => {
283
+ const jobs = '{other:pkg-a}';
284
+ const result = resolveOtherPlaceholders(jobs, ['pkg-a', 'pkg-b', 'pkg-c'], '/root');
285
+ expect(result).toContain('pkg-b');
286
+ expect(result).toContain('pkg-c');
287
+ expect(result).not.toContain('{other:');
288
+ });
289
+
290
+ it('does not include the current package in its own {other} expansion', () => {
291
+ const jobs = '{other:pkg-a}';
292
+ const result = resolveOtherPlaceholders(jobs, ['pkg-a', 'pkg-b'], '/root');
293
+ // The line for pkg-a should reference pkg-b but not pkg-a
294
+ expect(result).toBe('/root/pkg-b');
295
+ });
296
+
297
+ it('leaves jobs unchanged when there are no {other:*} sentinels', () => {
298
+ const jobs = 'no sentinels here';
299
+ expect(resolveOtherPlaceholders(jobs, ['pkg-a', 'pkg-b'], '/root')).toBe('no sentinels here');
300
+ });
301
+ });
302
+
303
+ // ─── applyVarsToDocTemplate ───────────────────────────────────────────────────
304
+
305
+ describe('applyVarsToDocTemplate', () => {
306
+ it('replaces a single named placeholder', () => {
307
+ const doc = 'jobs:\n {jobs}';
308
+ const result = applyVarsToDocTemplate(doc, [{ name: 'jobs', text: '- job: build' }]);
309
+ expect(result).toBe('jobs:\n - job: build');
310
+ });
311
+
312
+ it('replaces multiple named placeholders', () => {
313
+ const doc = '{a} and {b}';
314
+ const result = applyVarsToDocTemplate(doc, [
315
+ { name: 'a', text: 'AAA' },
316
+ { name: 'b', text: 'BBB' },
317
+ ]);
318
+ expect(result).toBe('AAA and BBB');
319
+ });
320
+
321
+ it('replaces multiple occurrences of the same placeholder', () => {
322
+ const doc = '{jobs}\n---\n{jobs}';
323
+ const result = applyVarsToDocTemplate(doc, [{ name: 'jobs', text: 'X' }]);
324
+ expect(result).toBe('X\n---\nX');
325
+ });
326
+
327
+ it('returns the template unchanged when varResults is empty', () => {
328
+ const doc = 'no placeholders';
329
+ expect(applyVarsToDocTemplate(doc, [])).toBe('no placeholders');
330
+ });
331
+ });
332
+
333
+ // ─── wrapWithBanner ───────────────────────────────────────────────────────────
334
+
335
+ describe('wrapWithBanner', () => {
336
+ it('starts and ends with the banner line', () => {
337
+ const result = wrapWithBanner('content');
338
+ expect(result.startsWith('#######################################')).toBe(true);
339
+ expect(result.endsWith('#######################################')).toBe(true);
340
+ });
341
+
342
+ it('contains the DO NOT EDIT text', () => {
343
+ const result = wrapWithBanner('content');
344
+ expect(result).toContain('DO NOT EDIT - THIS IS GENERATED');
345
+ });
346
+
347
+ it('preserves the original content', () => {
348
+ const result = wrapWithBanner('my yaml content');
349
+ expect(result).toContain('my yaml content');
350
+ });
351
+
352
+ it('wraps content with a blank line on each side', () => {
353
+ const result = wrapWithBanner('body');
354
+ expect(result).toContain('\n\nbody\n\n');
355
+ });
356
+ });
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "@acme/my-component",
3
+ "version": "1.0.0"
4
+ }
@@ -0,0 +1,5 @@
1
+ [project]
2
+ name = "my-python-tool"
3
+ version = "0.1.0"
4
+ description = "A sample Python tool"
5
+ authors = [{ name = "Maintainer", email = "maintainer@example.com" }]
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "@acme/my-lib",
3
+ "version": "2.0.0"
4
+ }
package/index.js DELETED
@@ -1,167 +0,0 @@
1
- #!/usr/bin/env node
2
- import * as url from 'url';
3
- import path from 'path';
4
- import { globby } from 'globby';
5
- import { readFileSync, writeFileSync, existsSync } from "fs";
6
-
7
- const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
8
-
9
- function stripIt(ins) {
10
- return ins.replace(/( |\r\n|\n|\r)/gm,"");
11
- }
12
-
13
- async function main(options) {
14
- var configFile = path.join(__dirname, options.config);
15
- const try1 = configFile;
16
-
17
- if(!existsSync(configFile)) {
18
- configFile = path.join(options.config);
19
- }
20
-
21
- if(!existsSync(configFile)) {
22
- throw `enablement-build-template-generate: could not load config file ${configFile}, also tried ${try1}`;
23
- }
24
-
25
- const templatePath = path.dirname(configFile);
26
-
27
- const config = JSON.parse(readFileSync(configFile,'utf8'));
28
-
29
- const plist = [];
30
-
31
- let search = options.exclude;
32
- if(search.length) {
33
- for(let i=0;i<search.length;i++) {
34
- search[i]='!**/'+search[i];
35
- }
36
- }
37
- search.unshift(`./**/package.json`);
38
- search.push(`!**/node_modules`);
39
-
40
- Object.keys(config).forEach((templateFilename)=>{
41
-
42
- plist.push(new Promise(async (primaryResolve, reject) => {
43
-
44
- const docTemplate = readFileSync(path.join(templatePath, templateFilename), "utf8");
45
- const jlist = [];
46
- Object.keys(config[templateFilename]).forEach((varname)=>{
47
- const jobConfig = config[templateFilename][varname];
48
- const job = new Promise(async (resolve, reject) => {
49
- const jobTemplate = readFileSync(path.join(templatePath, jobConfig.template), "utf8");
50
- const cwd = path.join(templatePath, jobConfig.rootPath);
51
-
52
- let jobRoot = jobConfig.rootPath.replace(/^[\.\/\\]+/, '');
53
-
54
-
55
- console.log(search);
56
-
57
- globby(search,{ cwd: cwd }).then((images)=>{
58
- images.sort();
59
- let jobs = [];
60
- let exclude = jobConfig.exclude||[];
61
- const allNames = [];
62
-
63
- for (let i = 0; i < images.length; i++) {
64
- let name = images[i].replace(/\/package\.json/g, '');
65
- allNames.push(name);
66
- if(name.startsWith("_")) continue;
67
-
68
- const packageJson = JSON.parse(readFileSync(path.join(cwd,images[i]),'utf8'));
69
- let fullName = packageJson.name;
70
- let packageName = packageJson.name;
71
- if(packageName.startsWith('@')) {
72
- packageName = packageName.split("\/")[1];
73
- }
74
-
75
- let job = jobTemplate;
76
- if(exclude.indexOf(name)>=0) continue;
77
- let safe = name.replace(/-/g, '').replace(/_/g, '').replace(/\./g, '');
78
- job = job.replace(/{other}/g, `{other:${name}}`).replace(/{path}/g,jobRoot).replace(/{name}/g, name).replace(/{safe}/g, safe).replace(/{package}/g, packageName).replace(/{full}/g, fullName);
79
- jobs.push(job);
80
- }
81
-
82
- jobs = jobs.join('\n');
83
- for (let i = 0; i < allNames.length; i++) {
84
- let cnames = allNames.slice();
85
- cnames.splice(i,1);
86
- cnames = cnames.join(`, ${cwd}/`);
87
- let rex = `{other:${allNames[i]}}`;
88
- rex=new RegExp(rex,'g');
89
- jobs = jobs.replace(rex, `${cwd}/${cnames}`);
90
- }
91
-
92
- resolve({name:varname, text:jobs});
93
- },reject);
94
-
95
- });
96
- jlist.push(job);
97
- });
98
-
99
- Promise.all(jlist).then((results)=>{
100
- var output = docTemplate;
101
-
102
- Object.keys(results).forEach((idx)=>{
103
- const varname = results[idx].name;
104
- let rex = new RegExp('\\{'+varname+'\\}',"g");
105
- output = output.replace(rex, results[idx].text);
106
- });
107
-
108
- const line = "#######################################";
109
- const donotedit = "### DO NOT EDIT - THIS IS GENERATED ###";
110
- output = `${line}\n${donotedit}\n${line}\n\n${output}\n\n${line}\n${donotedit}\n${line}`;
111
-
112
- if(stripIt(output)!==docTemplate) {
113
- const outFile = path.join(templatePath, `../${templateFilename}`);
114
- writeFileSync(path.join(templatePath, `../${templateFilename}`), output, "utf8");
115
- }
116
-
117
- primaryResolve(`generated template ${templateFilename}`);
118
- },reject);
119
- }));
120
- })
121
-
122
- return Promise.all(plist);
123
- }
124
-
125
- let options = {
126
- config: "./.cicd/templates/templated.json",
127
- exclude: [],
128
- debug: false,
129
- push: true
130
- }
131
-
132
- let argv = process.argv;
133
-
134
- for (let i = 2; i < argv.length; i++) {
135
- if (argv[i] === "--debug") { options.debug = true; continue; }
136
- if (argv[i].substring(0, 2) === "--") {
137
- let name = argv[i].substring(2);
138
- if (name==="exclude") {
139
- options[name] = argv[i + 1].replace(/\s/g,',').split(',');
140
- i++;
141
- } else if (options[name] !== undefined) {
142
- options[name] = argv[i + 1];
143
- i++;
144
- } else {
145
- console.error('Expected a known option');
146
- process.exit(1);
147
- }
148
- }
149
- }
150
-
151
- (async () => {
152
- try {
153
- console.log('\x1b[32m%s\x1b[0m', "Running enablement-build-template-generate with options:\n", JSON.stringify(options, null, 2));
154
- main(options).then((results) => {
155
- console.log(results.join('\n'));
156
- if (options.debug) console.log("DONE");
157
- process.exit(0);
158
- }, (e) => {
159
- console.log('\x1b[32m%s\x1b[0m',e);
160
- if (options.debug) console.log("ERROR");
161
- process.exit(1);
162
- })
163
- } catch (e) {
164
- console.log('\x1b[32m%s\x1b[0m',e);
165
- process.exit(1);
166
- }
167
- })();