enablement-build-template-generate 1.0.16 → 1.0.17

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/README.md CHANGED
@@ -12,9 +12,9 @@ npx enablement-build-template-generate@latest [flags]
12
12
 
13
13
  ## How it works
14
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.
15
+ 1. **Detect platform** — Checks for `manifest.json` (preferred) or `templated.json` (legacy) in `.cicd/` (ADO) or `.github/` (GitHub). Defaults to `ado` if none are found, or use `--platform` to override.
16
+ 2. **Read config** — If `manifest.json` is present, reads the `templated` section (each package maps to its own job template file). If only `templated.json` is present, reads the legacy `{ rootPath, template }` format and globs for packages automatically.
17
+ 3. **Expand job templates** — For each package, reads its job snippet template and fills in placeholders. When using `manifest.json`, metadata (`folderName`, `packageFolder`) comes from `manifest.cicd`; when using `templated.json`, package names are read from each folder's manifest file.
18
18
  4. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
19
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
20
 
@@ -24,55 +24,55 @@ npx enablement-build-template-generate@latest [flags]
24
24
 
25
25
  | Flag | Default | Description |
26
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. |
27
+ | `--platform <name>` | auto-detect | Force the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). Auto-detected from `manifest.json` or `templated.json` presence in `.cicd/` or `.github/`. |
29
28
  | `--exclude <list>` | — | Comma- or space-separated list of package folder names to skip during generation. |
30
29
  | `--debug` | off | Print verbose output. |
31
30
 
32
31
  ---
33
32
 
34
- ## Config file format
33
+ ## Config source
35
34
 
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.
35
+ The generator supports two config formats, checked in this order:
36
+
37
+ ### 1. `manifest.json` (preferred)
38
+
39
+ Reads the `templated` section of `{configDir}/manifest.json` (produced by `enablement-build-manifest`). Each key is the **output filename**; each value maps a placeholder variable name to a per-package template mapping.
37
40
 
38
41
  ```json
39
42
  {
40
- "ci-build.yaml": {
41
- "jobs": {
42
- "template": "job-template.yaml",
43
- "rootPath": "./packages",
44
- "exclude": ["internal-tool"]
43
+ "templated": {
44
+ "build.yml": {
45
+ "PACKAGES": {
46
+ "my-scope/component-a": "build_node_component.yml",
47
+ "my-scope/component-b": ["build_node_component.yml", "build_scan_node.yml"],
48
+ "my-scope/python-lib": ["build_python_package.yml", "build_scan.yml"]
49
+ }
45
50
  }
46
51
  }
47
52
  }
48
53
  ```
49
54
 
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
+ Each package name maps to a **single template filename** or an **array of template filenames** (all expanded in order for that package). Template lookup tries the active platform templates directory first (`.cicd/templates/` or `.github/templates/`) and then falls back to the other platform directory if a file is missing.
56
+
57
+ Package metadata (`folderName`, `packageFolder`) is read from `manifest.cicd[packageName]` and used to fill template placeholders.
58
+
59
+ ### 2. `templated.json` (legacy fallback)
55
60
 
56
- Multiple output files and multiple variables per output file are supported:
61
+ When `manifest.json` is absent, reads `{configDir}/templates/templated.json`. Each entry provides a `rootPath` to glob for packages and a single `template` applied to all discovered packages. Template lookup still uses active-platform-first, then falls back to the other platform templates folder if needed.
57
62
 
58
63
  ```json
59
64
  {
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"]
65
+ "build.yml": {
66
+ "PACKAGES": {
67
+ "rootPath": "../../packages",
68
+ "template": "build_node_component.yml"
71
69
  }
72
70
  }
73
71
  }
74
72
  ```
75
73
 
74
+ Package names are read from each folder's `package.json`, `version.json`, or `pyproject.toml`.
75
+
76
76
  ---
77
77
 
78
78
  ## Template placeholders
@@ -82,71 +82,52 @@ Multiple output files and multiple variables per output file are supported:
82
82
  The document template (the top-level YAML file) uses `{variableName}` placeholders that are replaced with the fully expanded set of job snippets:
83
83
 
84
84
  ```yaml
85
- # ci-build.yaml (document template)
85
+ # build.yml (document template)
86
86
  stages:
87
87
  - stage: Build
88
88
  jobs:
89
- {jobs}
89
+ {PACKAGES}
90
90
  ```
91
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
92
  ### Job template
105
93
 
106
94
  Each job snippet template is expanded once per package. The following placeholders are available:
107
95
 
108
96
  | Placeholder | Value |
109
97
  |---|---|
110
- | `{name}` | Package folder name (e.g. `my-component`) |
98
+ | `{name}` | Package folder name (from `cicd[pkg].folderName`, e.g. `my-component`) |
111
99
  | `{safe}` | Folder name with `-`, `_`, and `.` removed — safe for use as a YAML identifier (e.g. `mycomponent`) |
112
100
  | `{package}` | npm package name without scope prefix (e.g. `my-component` from `@scope/my-component`) |
113
101
  | `{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
- ```
102
+ | `{path}` | The package folder root (from `cicd[pkg].packageFolder`, e.g. `packages`) |
103
+ | `{other}` | Expands to a comma-separated list of paths to every **other** package in the same variable — useful for pipeline dependency declarations |
125
104
 
126
105
  ---
127
106
 
128
107
  ## Directory layout
129
108
 
130
- Templates and config live inside a subfolder; generated files are written one level up.
109
+ Job template files live in the templates subfolder; document templates and generated output files are one level up.
131
110
 
132
- **Azure DevOps** (default when no `.github/` directory):
111
+ **Azure DevOps** (when `.cicd/manifest.json` exists):
133
112
  ```
134
113
  .cicd/
135
- ├── ci-build.yaml ← generated output
114
+ ├── manifest.json ← source of truth for templated config (preferred)
115
+ ├── build.yml ← generated output
136
116
  └── templates/
137
- ├── templated.json ← config file
138
- ├── document.yaml ← document template
139
- └── job.yaml ← job snippet template
117
+ ├── build.yml ← document template
118
+ ├── templated.json ← legacy config (used when manifest.json is absent)
119
+ └── build_node_component.yml ← job snippet template
140
120
  ```
141
121
 
142
- **GitHub Actions** (when `.github/` directory is present):
122
+ **GitHub Actions** (when `.github/manifest.json` exists):
143
123
  ```
144
124
  .github/
145
- ├── ci-build.yaml ← generated output
125
+ ├── manifest.json ← source of truth for templated config (preferred)
126
+ ├── build.yml ← generated output
146
127
  └── templates/
147
- ├── templated.json ← config file
148
- ├── document.yaml ← document template
149
- └── job.yaml ← job snippet template
128
+ ├── build.yml ← document template
129
+ ├── templated.json ← legacy config (used when manifest.json is absent)
130
+ └── build_node_component.yml ← job snippet template
150
131
  ```
151
132
 
152
133
  ---
@@ -160,12 +141,6 @@ Run the generator as a pipeline step before the build stage:
160
141
  displayName: Generate pipeline YAML
161
142
  ```
162
143
 
163
- With a custom config location:
164
-
165
- ```bash
166
- npx enablement-build-template-generate@latest --config .azuredevops/templates/templated.json
167
- ```
168
-
169
144
  Excluding specific packages:
170
145
 
171
146
  ```bash
@@ -176,7 +151,7 @@ npx enablement-build-template-generate@latest --exclude legacy-pkg,test-harness
176
151
 
177
152
  ## Output format
178
153
 
179
- Generated files are wrapped in `DO NOT EDIT` banners. Edit the templates, not the output:
154
+ Generated files are wrapped in `DO NOT EDIT` banners. Edit the templates or re-generate via `manifest`, not the output directly:
180
155
 
181
156
  ```yaml
182
157
  #######################################
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "enablement-build-template-generate",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Generates build yaml for mono repos",
5
5
  "type": "module",
6
6
  "module": "./src/index.mjs",
7
7
  "main": "./src/index.mjs",
8
- "bin": "./src/index.mjs",
9
8
  "dependencies": {
10
- "globby": "^14.0.1",
9
+ "globby": "^16.2.0",
11
10
  "user": "^0.0.0"
12
11
  },
13
12
  "devDependencies": {
14
- "jest": "^29.0.0"
13
+ "jest": "^30.4.2"
15
14
  },
16
15
  "jest": {
17
16
  "testEnvironment": "node",
@@ -25,5 +24,8 @@
25
24
  "start": "node src/index.mjs",
26
25
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
27
26
  "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
27
+ },
28
+ "bin": {
29
+ "enablement-build-template-generate": "./src/index.mjs"
28
30
  }
29
31
  }
package/src/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import * as url from 'url';
3
3
  import path from 'path';
4
- import { globby } from 'globby';
5
4
  import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { globby } from 'globby';
6
6
 
7
7
  import {
8
8
  stripWhitespace,
@@ -19,87 +19,181 @@ import {
19
19
 
20
20
  const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
21
21
 
22
- function readManifestName(filePath) {
23
- const content = readFileSync(filePath, 'utf8');
24
- const fileType = filePath.endsWith('.toml') ? 'toml' : 'json';
25
- return parseManifestName(content, fileType);
22
+ // ---------------------------------------------------------------------------
23
+ // Read a job template file, falling back to the alternate platform folder
24
+ // ---------------------------------------------------------------------------
25
+ function readJobTemplate(templateFile, templatePath, fallbackTemplatePath) {
26
+ const primary = path.join(templatePath, templateFile);
27
+ if (existsSync(primary)) return readFileSync(primary, 'utf8');
28
+ if (fallbackTemplatePath) {
29
+ const fallback = path.join(fallbackTemplatePath, templateFile);
30
+ if (existsSync(fallback)) return readFileSync(fallback, 'utf8');
31
+ }
32
+ throw new Error(`Template file not found: ${templateFile} (looked in ${templatePath}${fallbackTemplatePath ? ' and ' + fallbackTemplatePath : ''})`);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Expand using manifest.json "templated" section (new format)
37
+ // ---------------------------------------------------------------------------
38
+ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options) {
39
+ return new Promise((resolve, reject) => {
40
+ try {
41
+ const packageNames = Object.keys(packageMap).filter(pkg => {
42
+ if (packageMap[pkg] === false) return false;
43
+ const folderName = (cicd[pkg] || {}).folderName || pkg;
44
+ return !options.exclude.includes(folderName);
45
+ });
46
+
47
+ const allFolderNames = packageNames.map(pkg => (cicd[pkg] || {}).folderName || pkg);
48
+
49
+ const folderCounts = {};
50
+ packageNames.forEach(pkg => {
51
+ const f = (cicd[pkg] || {}).packageFolder || 'packages';
52
+ folderCounts[f] = (folderCounts[f] || 0) + 1;
53
+ });
54
+ const commonPackageFolder = Object.keys(folderCounts).sort((a, b) => folderCounts[b] - folderCounts[a])[0] || 'packages';
55
+ const cwd = path.join(process.cwd(), commonPackageFolder);
56
+
57
+ const expandedJobs = packageNames.map(pkgName => {
58
+ const templateFiles = Array.isArray(packageMap[pkgName])
59
+ ? packageMap[pkgName]
60
+ : [packageMap[pkgName]];
61
+
62
+ const info = cicd[pkgName] || {};
63
+ const name = info.folderName || pkgName;
64
+ const jobRoot = info.packageFolder || 'packages';
65
+ const safe = makeSafe(name);
66
+ const packageName = resolvePackageName(pkgName);
67
+ const metadata = info.metadata || {};
68
+
69
+ return templateFiles.map(templateFile => {
70
+ const jobTemplate = readJobTemplate(templateFile, templatePath, fallbackTemplatePath);
71
+ return expandJobTemplate(jobTemplate, { name, safe, packageName, fullName: pkgName, jobRoot, metadata });
72
+ }).join('\n');
73
+ });
74
+
75
+ const jobs = resolveOtherPlaceholders(expandedJobs.join('\n'), allFolderNames, cwd);
76
+ resolve({ name: varname, text: jobs });
77
+ } catch (e) {
78
+ reject(e);
79
+ }
80
+ });
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Expand using templated.json (legacy format: rootPath + template glob)
85
+ // ---------------------------------------------------------------------------
86
+ function expandVarFromTemplatedJson(varname, jobConfig, templatePath, fallbackTemplatePath, options) {
87
+ return new Promise((resolve, reject) => {
88
+ const jobTemplate = readJobTemplate(jobConfig.template, templatePath, fallbackTemplatePath);
89
+ const cwd = path.join(templatePath, jobConfig.rootPath);
90
+ const search = buildSearchGlobs(options.exclude);
91
+
92
+ globby(search, { cwd }).then((files) => {
93
+ const folderBest = groupByFolder(files);
94
+ const allNames = Object.keys(folderBest).sort().filter(n => !options.exclude.includes(n));
95
+
96
+ const expandedJobs = allNames.map(name => {
97
+ const content = readFileSync(path.join(cwd, folderBest[name]), 'utf8');
98
+ const ext = path.extname(folderBest[name]).substring(1).toLowerCase();
99
+ const fullName = parseManifestName(content, ext) || name;
100
+ const safe = makeSafe(name);
101
+ const packageName = resolvePackageName(fullName);
102
+ const jobRoot = path.relative(process.cwd(), cwd);
103
+ return expandJobTemplate(jobTemplate, { name, safe, packageName, fullName, jobRoot, metadata: {} });
104
+ });
105
+
106
+ const jobs = resolveOtherPlaceholders(expandedJobs.join('\n'), allNames, cwd);
107
+ resolve({ name: varname, text: jobs });
108
+ }, reject);
109
+ });
26
110
  }
27
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // main
114
+ // ---------------------------------------------------------------------------
28
115
  async function main(options) {
29
- let configFile = path.join(__dirname, options.config);
30
- const try1 = configFile;
116
+ const configDir = options.platform === 'github' ? '.github' : '.cicd';
117
+ const altConfigDir = options.platform === 'github' ? '.cicd' : '.github';
118
+ const templatePath = path.join(process.cwd(), `${configDir}/templates`);
119
+ const fallbackTemplatePath = path.join(process.cwd(), `${altConfigDir}/templates`);
31
120
 
32
- if (!existsSync(configFile)) {
33
- configFile = path.join(options.config);
121
+ // Try manifest.json first, then fall back to templated.json
122
+ const manifestPath = path.join(process.cwd(), `${configDir}/manifest.json`);
123
+ const templatedJsonPath = path.join(process.cwd(), `${configDir}/templates/templated.json`);
124
+
125
+ const useManifest = existsSync(manifestPath);
126
+ const useTemplatedJson = !useManifest && existsSync(templatedJsonPath);
127
+
128
+ if (!useManifest && !useTemplatedJson) {
129
+ throw `enablement-build-template-generate: could not find manifest.json at ${configDir}/manifest.json or templated.json at ${configDir}/templates/templated.json`;
34
130
  }
35
131
 
36
- if (!existsSync(configFile)) {
37
- throw `enablement-build-template-generate: could not load config file ${configFile}, also tried ${try1}`;
132
+ let config;
133
+ let cicd = {};
134
+
135
+ if (useManifest) {
136
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
137
+ if (!manifest.templated) {
138
+ throw `enablement-build-template-generate: manifest.json has no "templated" section`;
139
+ }
140
+ config = manifest.templated;
141
+ cicd = manifest.cicd || {};
142
+ } else {
143
+ config = JSON.parse(readFileSync(templatedJsonPath, 'utf8'));
38
144
  }
39
145
 
40
- const templatePath = path.dirname(configFile);
41
- const config = JSON.parse(readFileSync(configFile, 'utf8'));
42
- const search = buildSearchGlobs(options.exclude);
43
146
  const plist = [];
44
147
 
45
148
  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);
149
+ plist.push(new Promise((primaryResolve, reject) => {
150
+ try {
151
+ const docTemplate = readFileSync(path.join(templatePath, templateFilename), 'utf8');
152
+ const jlist = [];
153
+
154
+ Object.keys(config[templateFilename]).forEach((varname) => {
155
+ const varConfig = config[templateFilename][varname];
156
+
157
+ if (useManifest) {
158
+ // New format: varConfig is { pkgName: "template.yml" | [...] }
159
+ jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options));
160
+ } else {
161
+ // Legacy format: varConfig is { rootPath, template, exclude? }
162
+ jlist.push(expandVarFromTemplatedJson(varname, varConfig, templatePath, fallbackTemplatePath, options));
163
+ }
74
164
  });
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
165
 
86
- primaryResolve(`generated template ${templateFilename}`);
87
- }, reject);
166
+ Promise.all(jlist).then((results) => {
167
+ const output = applyVarsToDocTemplate(docTemplate, results);
168
+ const wrapped = wrapWithBanner(output);
169
+
170
+ const outPath = path.join(templatePath, `../${templateFilename}`);
171
+ const existing = existsSync(outPath) ? readFileSync(outPath, 'utf8') : '';
172
+ if (stripWhitespace(wrapped) !== stripWhitespace(existing)) {
173
+ writeFileSync(outPath, wrapped, 'utf8');
174
+ }
175
+
176
+ primaryResolve(`generated template ${templateFilename}`);
177
+ }, reject);
178
+ } catch (e) {
179
+ reject(e);
180
+ }
88
181
  }));
89
182
  });
90
183
 
91
184
  return Promise.all(plist);
92
185
  }
93
186
 
187
+ // ---------------------------------------------------------------------------
188
+ // CLI
189
+ // ---------------------------------------------------------------------------
94
190
  let options = {
95
- config: null,
96
191
  platform: null,
97
192
  exclude: [],
98
193
  debug: false,
99
194
  };
100
195
 
101
196
  let argv = process.argv;
102
-
103
197
  for (let i = 2; i < argv.length; i++) {
104
198
  if (argv[i] === '--debug') { options.debug = true; continue; }
105
199
  if (argv[i].substring(0, 2) === '--') {
@@ -111,21 +205,25 @@ for (let i = 2; i < argv.length; i++) {
111
205
  options[name] = argv[i + 1];
112
206
  i++;
113
207
  } else {
114
- console.error('Expected a known option');
208
+ console.error(`Expected a known option, got ${argv[i]}`);
115
209
  process.exit(1);
116
210
  }
117
211
  }
118
212
  }
119
213
 
120
214
  if (!options.platform) {
121
- const hasGithubConfig = existsSync(path.join(process.cwd(), '.github/templates/templated.json'));
122
- const hasAdoConfig = existsSync(path.join(process.cwd(), '.cicd/templates/templated.json'));
123
- options.platform = hasGithubConfig ? 'github' : hasAdoConfig ? 'ado' : 'github';
124
- }
125
- if (!options.config) {
126
- options.config = options.platform === 'github'
127
- ? '.github/templates/templated.json'
128
- : '.cicd/templates/templated.json';
215
+ const hasGithubManifest = existsSync(path.join(process.cwd(), '.github/manifest.json'));
216
+ const hasAdoManifest = existsSync(path.join(process.cwd(), '.cicd/manifest.json'));
217
+ const hasGithubTemplated = existsSync(path.join(process.cwd(), '.github/templates/templated.json'));
218
+ const hasAdoTemplated = existsSync(path.join(process.cwd(), '.cicd/templates/templated.json'));
219
+
220
+ if (hasGithubManifest || hasGithubTemplated) {
221
+ options.platform = 'github';
222
+ } else if (hasAdoManifest || hasAdoTemplated) {
223
+ options.platform = 'ado';
224
+ } else {
225
+ options.platform = 'ado';
226
+ }
129
227
  }
130
228
 
131
229
  (async () => {
package/src/lib.mjs CHANGED
@@ -70,14 +70,21 @@ export function makeSafe(name) {
70
70
 
71
71
  // Expand a job template string for one package, substituting all known placeholders.
72
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
73
+ export function expandJobTemplate(template, { name, safe, packageName, fullName, jobRoot, metadata = {} }) {
74
+ let output = template
75
75
  .replace(/{other}/g, `{other:${name}}`)
76
76
  .replace(/{path}/g, jobRoot)
77
77
  .replace(/{name}/g, name)
78
78
  .replace(/{safe}/g, safe)
79
79
  .replace(/{package}/g, packageName)
80
80
  .replace(/{full}/g, fullName);
81
+
82
+ Object.entries(metadata).forEach(([key, value]) => {
83
+ if (value === undefined || value === null) return;
84
+ output = output.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
85
+ });
86
+
87
+ return output;
81
88
  }
82
89
 
83
90
  // Resolve all {other:name} sentinels in the combined jobs string.