enablement-build-template-generate 1.0.19 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,11 +12,13 @@ npx enablement-build-template-generate@latest [flags]
12
12
 
13
13
  ## How it works
14
14
 
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
- 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.
15
+ 1. **Select platform** — Uses Azure DevOps (`.cicd/`) by default. Pass `--platform github` to use GitHub (`.github/`).
16
+ 2. **Read config** — `{configDir}/templates/templated.json` is the source of truth. If `manifest.json` still has a `templated` section (from older tooling), it's read once, merged into `templated.json`, and removed from `manifest.json`. A `templated.json` in the legacy `{ rootPath, template }` shape is converted to the current format (packages discovered by globbing `rootPath`).
17
+ 3. **Detect new packages** — Any package not yet assigned a template (scanned via `--children`/auto-detected workspace roots) is classified by type (node/python/dotnet/docker) and assigned a template, reusing the convention of other packages in the same folder or of the same type where possible.
18
+ 4. **Persist** — `templated.json` (and `manifest.json`, if it needed migrating) is written back, only when something actually changed.
19
+ 5. **Expand job templates** — For each package, reads its job snippet template and fills in placeholders (`folderName`/`packageFolder`/`metadata` come from `templated.json`'s own `cicd` section). Packages are ordered so that, per `manifest.json`'s `dependencies` graph (when present), a package's local dependencies are generated before it, as much as possible.
20
+ 6. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
21
+ 7. **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
22
 
21
23
  ---
22
24
 
@@ -24,22 +26,22 @@ npx enablement-build-template-generate@latest [flags]
24
26
 
25
27
  | Flag | Default | Description |
26
28
  |---|---|---|
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/`. |
28
- | `--exclude <list>` | — | Comma- or space-separated list of package folder names to skip during generation. |
29
+ | `--platform <name>` | `ado` | Select the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). |
30
+ | `--children <dirs>` | auto-detect | Comma- or space-separated list of top-level directories to scan when detecting new (not-yet-configured) packages. When omitted, directories are read from `pnpm-workspace.yaml` or `package.json` workspaces automatically. |
31
+ | `--exclude <list>` | — | Comma- or space-separated list of names to skip: package folder names during generation, and top-level directories (from `--children` or auto-detection) during new-package scanning. |
29
32
  | `--debug` | off | Print verbose output. |
30
33
 
31
34
  ---
32
35
 
33
36
  ## Config source
34
37
 
35
- The generator supports two config formats, checked in this order:
38
+ ### `templated.json` (current format)
36
39
 
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.
40
+ `{configDir}/templates/templated.json` is the source of truth for template routing. Each key under `templated` is the **output filename**; each value maps a placeholder variable name to a per-package template mapping. `cicd` carries the per-package metadata generation needs.
40
41
 
41
42
  ```json
42
43
  {
44
+ "version": 2,
43
45
  "templated": {
44
46
  "build.yml": {
45
47
  "PACKAGES": {
@@ -48,30 +50,24 @@ Reads the `templated` section of `{configDir}/manifest.json` (produced by `enabl
48
50
  "my-scope/python-lib": ["build_python_package.yml", "build_scan.yml"]
49
51
  }
50
52
  }
53
+ },
54
+ "cicd": {
55
+ "my-scope/component-a": { "folderName": "component-a", "packageFolder": "packages" }
51
56
  }
52
57
  }
53
58
  ```
54
59
 
55
60
  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
61
 
57
- Package metadata (`folderName`, `packageFolder`) is read from `manifest.cicd[packageName]` and used to fill template placeholders.
62
+ Package metadata (`folderName`, `packageFolder`, `metadata`) is read from `templated.json`'s own `cicd` map and used to fill template placeholders.
58
63
 
59
- ### 2. `templated.json` (legacy fallback)
64
+ A `templated.json` in the legacy `{ rootPath, template }` shape (no top-level `templated`/`version` key) is converted to the current format automatically — packages are discovered by globbing `rootPath` for `package.json`/`version.json`/`pyproject.toml`, and the result is written back.
60
65
 
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.
66
+ ### `manifest.json` (one-time migration source + dependency graph)
62
67
 
63
- ```json
64
- {
65
- "build.yml": {
66
- "PACKAGES": {
67
- "rootPath": "../../packages",
68
- "template": "build_node_component.yml"
69
- }
70
- }
71
- }
72
- ```
68
+ If `{configDir}/manifest.json` still has a `templated` section (from older tooling, or `enablement-build-manifest` before it stopped writing one), it's merged into `templated.json` and removed from `manifest.json` — existing assignments in `templated.json` win over stale ones from `manifest.json`.
73
69
 
74
- Package names are read from each folder's `package.json`, `version.json`, or `pyproject.toml`.
70
+ Independently of that migration, when `manifest.json` exists its `dependencies` map (`{ "<package>": ["<local-dep>", ...] }`) is used to order generation: within each placeholder, a package's local dependencies are generated before it, as much as possible (dependencies outside that placeholder's package list, or on a package that also depends back on it, are simply skipped rather than causing an error).
75
71
 
76
72
  ---
77
73
 
@@ -101,6 +97,9 @@ Each job snippet template is expanded once per package. The following placeholde
101
97
  | `{full}` | Full npm package name including scope (e.g. `@scope/my-component`) |
102
98
  | `{path}` | The package folder root (from `cicd[pkg].packageFolder`, e.g. `packages`) |
103
99
  | `{other}` | Expands to a comma-separated list of paths to every **other** package in the same variable — useful for pipeline dependency declarations |
100
+ | `{metadataKey}` | Custom placeholders like `{owner}` or `{tier}` are replaced from `templated.json`'s `cicd[packageName].metadata` |
101
+
102
+ Custom metadata placeholders aren't supported for a package still being discovered via the legacy `{ rootPath, template }` conversion (they remain unchanged in output) — once converted, `templated.json`'s `cicd` map can carry `metadata` for any package.
104
103
 
105
104
  ---
106
105
 
@@ -108,25 +107,25 @@ Each job snippet template is expanded once per package. The following placeholde
108
107
 
109
108
  Job template files live in the templates subfolder; document templates and generated output files are one level up.
110
109
 
111
- **Azure DevOps** (when `.cicd/manifest.json` exists):
110
+ **Azure DevOps**:
112
111
  ```
113
112
  .cicd/
114
- ├── manifest.json ← source of truth for templated config (preferred)
113
+ ├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
115
114
  ├── build.yml ← generated output
116
115
  └── templates/
117
116
  ├── build.yml ← document template
118
- ├── templated.json ← legacy config (used when manifest.json is absent)
117
+ ├── templated.json ← source of truth for templated config
119
118
  └── build_node_component.yml ← job snippet template
120
119
  ```
121
120
 
122
- **GitHub Actions** (when `.github/manifest.json` exists):
121
+ **GitHub Actions**:
123
122
  ```
124
123
  .github/
125
- ├── manifest.json ← source of truth for templated config (preferred)
124
+ ├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
126
125
  ├── build.yml ← generated output
127
126
  └── templates/
128
127
  ├── build.yml ← document template
129
- ├── templated.json ← legacy config (used when manifest.json is absent)
128
+ ├── templated.json ← source of truth for templated config
130
129
  └── build_node_component.yml ← job snippet template
131
130
  ```
132
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enablement-build-template-generate",
3
- "version": "1.0.19",
3
+ "version": "1.3.0",
4
4
  "description": "Generates build yaml for mono repos",
5
5
  "type": "module",
6
6
  "module": "./src/index.mjs",
@@ -8,7 +8,8 @@
8
8
  "bin": "./src/index.mjs",
9
9
  "dependencies": {
10
10
  "globby": "^16.2.0",
11
- "user": "^0.0.0"
11
+ "user": "^0.0.0",
12
+ "enablement-build-manifest": "1.4.0"
12
13
  },
13
14
  "devDependencies": {
14
15
  "jest": "^30.4.2"
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import * as url from 'url';
3
3
  import path from 'path';
4
- import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
5
5
  import { globby } from 'globby';
6
6
 
7
7
  import {
@@ -15,6 +15,17 @@ import {
15
15
  resolveOtherPlaceholders,
16
16
  applyVarsToDocTemplate,
17
17
  wrapWithBanner,
18
+ resolveWorkspaceChildren,
19
+ excludeChildren,
20
+ buildPackageJsonGlob,
21
+ detectCicdType,
22
+ isAdvancedTemplatedFormat,
23
+ TEMPLATED_JSON_ADVANCED_VERSION,
24
+ extractGenerationCicd,
25
+ collectAssignedPackages,
26
+ listBuildTemplateFiles,
27
+ chooseAssignmentForNewPackage,
28
+ topologicalSort,
18
29
  } from './lib.mjs';
19
30
 
20
31
  const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
@@ -32,19 +43,54 @@ function readJobTemplate(templateFile, templatePath, fallbackTemplatePath) {
32
43
  throw new Error(`Template file not found: ${templateFile} (looked in ${templatePath}${fallbackTemplatePath ? ' and ' + fallbackTemplatePath : ''})`);
33
44
  }
34
45
 
46
+
47
+ // Resolve package folder name for {name}: prefer cicd.folderName, otherwise infer from disk.
48
+ function resolveFolderName(pkgName, info = {}) {
49
+ if (info.folderName) return info.folderName;
50
+
51
+ const packageFolder = info.packageFolder || 'packages';
52
+ const packageRoot = path.join(process.cwd(), packageFolder);
53
+ if (!existsSync(packageRoot)) return resolvePackageName(pkgName);
54
+
55
+ const dirs = readdirSync(packageRoot, { withFileTypes: true })
56
+ .filter(entry => entry.isDirectory())
57
+ .map(entry => entry.name);
58
+
59
+ for (const dirName of dirs) {
60
+ const base = path.join(packageRoot, dirName);
61
+ const candidates = ['package.json', 'version.json', 'pyproject.toml'];
62
+
63
+ for (const candidate of candidates) {
64
+ const manifestPath = path.join(base, candidate);
65
+ if (!existsSync(manifestPath)) continue;
66
+
67
+ const ext = path.extname(candidate).substring(1).toLowerCase();
68
+ const content = readFileSync(manifestPath, 'utf8');
69
+ const parsedName = parseManifestName(content, ext);
70
+
71
+ if (parsedName === pkgName) return dirName;
72
+ }
73
+ }
74
+
75
+ return resolvePackageName(pkgName);
76
+ }
77
+
35
78
  // ---------------------------------------------------------------------------
36
79
  // Expand using manifest.json "templated" section (new format)
37
80
  // ---------------------------------------------------------------------------
38
- function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options) {
81
+ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options, dependencies = {}) {
39
82
  return new Promise((resolve, reject) => {
40
83
  try {
41
- const packageNames = Object.keys(packageMap).filter(pkg => {
84
+ const filteredNames = Object.keys(packageMap).filter(pkg => {
42
85
  if (packageMap[pkg] === false) return false;
43
- const folderName = (cicd[pkg] || {}).folderName || pkg;
86
+ const folderName = resolveFolderName(pkg, cicd[pkg] || {});
44
87
  return !options.exclude.includes(folderName);
45
88
  });
89
+ // Order so a package's local dependencies are generated before it,
90
+ // as much as possible (per manifest.json's "dependencies").
91
+ const packageNames = topologicalSort(filteredNames, dependencies);
46
92
 
47
- const allFolderNames = packageNames.map(pkg => (cicd[pkg] || {}).folderName || pkg);
93
+ const allFolderNames = packageNames.map(pkg => resolveFolderName(pkg, cicd[pkg] || {}));
48
94
 
49
95
  const folderCounts = {};
50
96
  packageNames.forEach(pkg => {
@@ -60,7 +106,7 @@ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallback
60
106
  : [packageMap[pkgName]];
61
107
 
62
108
  const info = cicd[pkgName] || {};
63
- const name = info.folderName || pkgName;
109
+ const name = resolveFolderName(pkgName, info);
64
110
  const jobRoot = info.packageFolder || 'packages';
65
111
  const safe = makeSafe(name);
66
112
  const packageName = resolvePackageName(pkgName);
@@ -81,86 +127,198 @@ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallback
81
127
  }
82
128
 
83
129
  // ---------------------------------------------------------------------------
84
- // Expand using templated.json (legacy format: rootPath + template glob)
130
+ // Convert one legacy templated.json entry ({ rootPath, template }) into the
131
+ // advanced format: discover the packages currently under rootPath and assign
132
+ // each of them the entry's template, recording their folder/packageFolder.
85
133
  // ---------------------------------------------------------------------------
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].filePath), 'utf8');
98
- const ext = path.extname(folderBest[name].filePath).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
- });
134
+ async function convertLegacyEntry(jobConfig, templatePath, options) {
135
+ const cwd = path.join(templatePath, jobConfig.rootPath);
136
+ const search = buildSearchGlobs(options.exclude);
137
+ const files = await globby(search, { cwd });
138
+
139
+ const folderBest = groupByFolder(files);
140
+ const allNames = Object.keys(folderBest).sort().filter(n => !options.exclude.includes(n));
141
+ const packageFolder = path.relative(process.cwd(), cwd);
142
+
143
+ const pkgMap = {};
144
+ const cicdEntries = {};
145
+ allNames.forEach(name => {
146
+ const content = readFileSync(path.join(cwd, folderBest[name].filePath), 'utf8');
147
+ const ext = path.extname(folderBest[name].filePath).substring(1).toLowerCase();
148
+ const fullName = parseManifestName(content, ext) || name;
149
+ pkgMap[fullName] = jobConfig.template;
150
+ cicdEntries[fullName] = { folderName: name, packageFolder };
151
+ });
105
152
 
106
- const jobs = resolveOtherPlaceholders(expandedJobs.join('\n'), allNames, cwd);
107
- resolve({ name: varname, text: jobs });
108
- }, reject);
153
+ return { pkgMap, cicdEntries };
154
+ }
155
+
156
+ // Convert a whole legacy-format templated.json document into the advanced
157
+ // { templated, cicd } shape.
158
+ async function convertLegacyTemplatedJson(rawConfig, templatePath, options) {
159
+ const templated = {};
160
+ const cicd = {};
161
+
162
+ for (const pipelineFile of Object.keys(rawConfig)) {
163
+ templated[pipelineFile] = {};
164
+ for (const varname of Object.keys(rawConfig[pipelineFile])) {
165
+ const { pkgMap, cicdEntries } = await convertLegacyEntry(rawConfig[pipelineFile][varname], templatePath, options);
166
+ templated[pipelineFile][varname] = pkgMap;
167
+ Object.assign(cicd, cicdEntries);
168
+ }
169
+ }
170
+
171
+ return { templated, cicd };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Discover packages not yet assigned to any template and add them, using
176
+ // detected package type + the existing convention (same packageFolder, else
177
+ // same output type, else the default "PACKAGES" placeholder). Mutates
178
+ // `templated`/`cicd` in place; returns true if anything was added.
179
+ // ---------------------------------------------------------------------------
180
+ async function detectAndAssignNewPackages(templated, cicd, options, templatePath) {
181
+ const assigned = collectAssignedPackages(templated);
182
+ const buildTemplateFiles = listBuildTemplateFiles(templatePath);
183
+
184
+ const pkgPaths = await globby(buildPackageJsonGlob(options.children), {
185
+ cwd: process.cwd(),
186
+ ignore: ['**/node_modules/**', '**/tests/**'],
187
+ absolute: true,
188
+ });
189
+
190
+ let added = false;
191
+
192
+ pkgPaths.forEach(pkgPath => {
193
+ let pkg;
194
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { return; }
195
+ if (!pkg.name || !pkg.version || assigned.has(pkg.name)) return;
196
+
197
+ const folderName = path.basename(path.dirname(pkgPath));
198
+ if (options.exclude.includes(folderName)) return;
199
+
200
+ const packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
201
+ const detected = detectCicdType(pkg.name, pkg, pkgPath);
202
+ const { pipelineFile, placeholder, template } = chooseAssignmentForNewPackage({
203
+ packageFolder,
204
+ output: detected ? detected.output : undefined,
205
+ templated,
206
+ cicd,
207
+ buildTemplateFiles,
208
+ });
209
+
210
+ if (!template) return;
211
+
212
+ templated[pipelineFile] = templated[pipelineFile] || {};
213
+ templated[pipelineFile][placeholder] = templated[pipelineFile][placeholder] || {};
214
+ templated[pipelineFile][placeholder][pkg.name] = template;
215
+ cicd[pkg.name] = { ...(cicd[pkg.name] || {}), folderName, packageFolder };
216
+ assigned.add(pkg.name);
217
+ added = true;
109
218
  });
219
+
220
+ return added;
110
221
  }
111
222
 
112
223
  // ---------------------------------------------------------------------------
113
224
  // main
114
225
  // ---------------------------------------------------------------------------
115
226
  async function main(options) {
227
+ // Detect workspace package roots (pnpm-workspace.yaml or package.json
228
+ // workspaces), same as monorepo-version and manifest. Used below to scope
229
+ // discovery of new (not-yet-configured) packages.
230
+ if (!options.children) {
231
+ const detected = resolveWorkspaceChildren(process.cwd());
232
+ if (detected) {
233
+ if (options.debug) console.log(`Auto-detected workspace children: ${detected}`);
234
+ options.children = detected;
235
+ }
236
+ }
237
+
238
+ // --exclude drops specific root directories from whichever children list is
239
+ // in effect, auto-detected or explicit. The same list is also used below to
240
+ // filter individual packages by folder name during generation.
241
+ if (options.exclude.length > 0) {
242
+ options.children = excludeChildren(options.children, options.exclude) || options.children;
243
+ }
244
+
116
245
  const configDir = options.platform === 'github' ? '.github' : '.cicd';
117
246
  const altConfigDir = options.platform === 'github' ? '.cicd' : '.github';
118
247
  const templatePath = path.join(process.cwd(), `${configDir}/templates`);
119
248
  const fallbackTemplatePath = path.join(process.cwd(), `${altConfigDir}/templates`);
120
249
 
121
- // Try manifest.json first, then fall back to templated.json
122
250
  const manifestPath = path.join(process.cwd(), `${configDir}/manifest.json`);
123
251
  const templatedJsonPath = path.join(process.cwd(), `${configDir}/templates/templated.json`);
124
252
 
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`;
253
+ // ── Load config: manifest.json's "templated" section (if still present, to
254
+ // be migrated out), else templated.json (new or legacy format). Whatever we
255
+ // load is normalized into { templated, cicd } once, up front, so generation
256
+ // below always runs the same (advanced-format) code path.
257
+ let templated;
258
+ let cicd;
259
+ let manifestToPersist = null; // set when manifest.json needs its "templated" section stripped
260
+ let templatedJsonDirty = false; // set when templated.json needs (re)writing
261
+ let dependencies = {}; // manifest.json's local dependency graph, for ordering generation
262
+
263
+ if (existsSync(manifestPath)) {
264
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8') || '{}');
265
+ dependencies = manifest.dependencies || {};
266
+ if (isAdvancedTemplatedFormat(manifest)) {
267
+ templated = manifest.templated;
268
+ cicd = extractGenerationCicd(manifest.cicd || {});
269
+ delete manifest.templated;
270
+ manifestToPersist = manifest;
271
+ templatedJsonDirty = true;
272
+ }
130
273
  }
131
274
 
132
- let config;
133
- let cicd = {};
275
+ if (!templated) {
276
+ if (!existsSync(templatedJsonPath)) {
277
+ throw `enablement-build-template-generate: could not find manifest.json "templated" section at ${configDir}/manifest.json or templated.json at ${configDir}/templates/templated.json`;
278
+ }
134
279
 
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`;
280
+ const raw = JSON.parse(readFileSync(templatedJsonPath, 'utf8') || '{}');
281
+ if (isAdvancedTemplatedFormat(raw)) {
282
+ templated = raw.templated;
283
+ cicd = raw.cicd || {};
284
+ if (typeof raw.version !== 'number') {
285
+ // Advanced format but predates the version field: write it back once,
286
+ // purely to backfill it, even if nothing else changed.
287
+ templatedJsonDirty = true;
288
+ }
289
+ } else {
290
+ const converted = await convertLegacyTemplatedJson(raw, templatePath, options);
291
+ templated = converted.templated;
292
+ cicd = converted.cicd;
293
+ templatedJsonDirty = true;
139
294
  }
140
- config = manifest.templated;
141
- cicd = manifest.cicd || {};
142
- } else {
143
- config = JSON.parse(readFileSync(templatedJsonPath, 'utf8'));
144
295
  }
145
296
 
297
+ // ── Discover packages not yet assigned to any template and add them.
298
+ if (await detectAndAssignNewPackages(templated, cicd, options, templatePath)) {
299
+ templatedJsonDirty = true;
300
+ }
301
+
302
+ // ── Persist. At most one write to each file, only when something changed.
303
+ if (manifestToPersist) {
304
+ writeFileSync(manifestPath, JSON.stringify(manifestToPersist, null, 2), 'utf8');
305
+ }
306
+ if (templatedJsonDirty) {
307
+ writeFileSync(templatedJsonPath, JSON.stringify({ version: TEMPLATED_JSON_ADVANCED_VERSION, templated, cicd }, null, 2), 'utf8');
308
+ }
309
+
310
+ // ── Generate.
146
311
  const plist = [];
147
312
 
148
- Object.keys(config).forEach((templateFilename) => {
313
+ Object.keys(templated).forEach((templateFilename) => {
149
314
  plist.push(new Promise((primaryResolve, reject) => {
150
315
  try {
151
316
  const docTemplate = readFileSync(path.join(templatePath, templateFilename), 'utf8');
152
317
  const jlist = [];
153
318
 
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
- }
319
+ Object.keys(templated[templateFilename]).forEach((varname) => {
320
+ const varConfig = templated[templateFilename][varname];
321
+ jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options, dependencies));
164
322
  });
165
323
 
166
324
  Promise.all(jlist).then((results) => {
@@ -188,9 +346,10 @@ async function main(options) {
188
346
  // CLI
189
347
  // ---------------------------------------------------------------------------
190
348
  let options = {
191
- platform: null,
349
+ platform: 'ado',
192
350
  exclude: [],
193
351
  debug: false,
352
+ children: null,
194
353
  };
195
354
 
196
355
  let argv = process.argv;
@@ -211,20 +370,6 @@ for (let i = 2; i < argv.length; i++) {
211
370
  }
212
371
  }
213
372
 
214
- if (!options.platform) {
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
- }
227
- }
228
373
 
229
374
  (async () => {
230
375
  try {