@onlineapps/conn-orch-validator 6.0.1 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +2591 -2
  2. package/README.md +1075 -7
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +422 -104
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +78 -42
  10. package/src/ValidationOrchestrator.js +298 -75
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +12 -2
  16. package/src/helpers/createServiceReadinessTests.js +75 -6
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +213 -13
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +21 -20
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +290 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * `npx oa-sync-template <what> --target <dir>` — writes a generated file of a
6
+ * bearer from the platform declaration that owns it, or checks that the file on
7
+ * disk is still what that declaration renders.
8
+ *
9
+ * Four runs, one template:
10
+ *
11
+ * * bare paths - the uniform sync of confirmation 001 §3.2. It writes the
12
+ * files of the manifest classes `identical` and `generated` from the very
13
+ * `from:` reference those rows already point the conformance CHECK at, and
14
+ * never touches a file of the `own` class. A row the manifest does not make
15
+ * renderable is reported NOT RUN by name, never filled from a guess.
16
+ * * `--new <name>` - the whole service tree, from the template that ships
17
+ * inside this package (001 §2). It is what `api/scripts/add-service.sh
18
+ * --scaffold` runs; that script keeps the platform facts (the registry
19
+ * entry, the port), because those are not the shape of a service.
20
+ * * `shared-env` - `config/env-templates/shared.env` from
21
+ * `api/config/shared-env.json`, the one owner of the shared key set (003 §18).
22
+ * * `readme-uniform` - the generated uniform pointer of a README, for a
23
+ * library or for a biz service; the kind is read from the target (005).
24
+ *
25
+ * There is no flag that points the run at another manifest: the shape has one
26
+ * owner beside the SSOT (004), and a `--manifest` flag would be a second one
27
+ * (`.claude/rules/automation-gates.md` §1 requirement 5).
28
+ *
29
+ * @see api/docs/governance/confirmations/biz-service-manifest.md §2, §3.2, §5, §18
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const { renderSharedEnv, checkSharedEnv } = require('../sync/sharedEnv');
36
+ const {
37
+ deriveParams,
38
+ renderTree,
39
+ IDENTITY_PARAMS,
40
+ listTemplateFiles,
41
+ listOutputFiles,
42
+ templateIgnores,
43
+ outputRelative,
44
+ EXECUTABLE_FILES,
45
+ ENV_TEMPLATE_SOURCE,
46
+ envTemplateTarget,
47
+ PLACEHOLDERS,
48
+ SSOT_PIN,
49
+ TEMPLATE_ROOT
50
+ } = require('../sync/serviceTemplate');
51
+ const { planSync, uniformRows } = require('../sync/uniformFiles');
52
+ const { loadManifest: loadUniformManifest, DEFAULT_MANIFEST_PATH } = require('../manifest/loadManifest');
53
+ const {
54
+ applyUniformRegion,
55
+ checkUniformRegion,
56
+ declaredCategory,
57
+ KINDS
58
+ } = require('../sync/readmePointer');
59
+ const {
60
+ README_FILE,
61
+ PACKAGE_ROOT,
62
+ SERVICE_MANIFEST_IN_PACKAGE,
63
+ LIBRARY_MANIFEST_IN_PACKAGE,
64
+ manifestInWorkspace,
65
+ serviceRegion,
66
+ libraryRegion
67
+ } = require('../sync/readmeLocation');
68
+ const docsRegion = require('../sync/docsRegion');
69
+ const { resolveWorkspaceRoot, canonicalRoot, WORKSPACE_MARKER } = require('../manifest/workspaceRoot');
70
+ const { loadManifest: loadLibraryManifest, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
71
+ const { discoverBearers } = require('../manifest/discovery');
72
+
73
+ /** Exit code for a run that could not start at all — distinct from a finding. */
74
+ const USAGE_EXIT = 2;
75
+
76
+ /**
77
+ * The manifest lives beside the SSOT the workspace marker names, so "where is
78
+ * the workspace" is asked once and answered in one place (004: platform-level
79
+ * facts live in `api/config/*.json` beside the SSOT).
80
+ */
81
+ const MANIFEST_RELATIVE = path.join(path.dirname(WORKSPACE_MARKER), 'shared-env.json');
82
+
83
+ /** Where a bearer keeps the file this subcommand generates. */
84
+ const SHARED_ENV_RELATIVE = path.join('config', 'env-templates', 'shared.env');
85
+
86
+ /** The same file as a tree key, which is always '/'-separated. */
87
+ const SHARED_ENV_POSIX = 'config/env-templates/shared.env';
88
+
89
+ /** The platform SSOT of library versions, relative to the workspace root. */
90
+ const LIBRARIES_RELATIVE = path.join(path.dirname(WORKSPACE_MARKER), 'libraries.json');
91
+
92
+ /** The scope prefix whose versions the SSOT owns. */
93
+ const PINNED_SCOPE = '@onlineapps/';
94
+
95
+ const USAGE = `
96
+ Usage:
97
+ oa-sync-template [path...] --target <serviceRoot> [--workspace <root>] [--check]
98
+ oa-sync-template --new <name> --into <serviceRoot> [--description <text>] [--workspace <root>]
99
+ oa-sync-template template --target <dir> [--check]
100
+ oa-sync-template shared-env --target <dir> [--workspace <root>] [--check]
101
+ oa-sync-template readme-uniform (--target <dir> | --all) [--workspace <root>] [--check]
102
+ oa-sync-template docs-region (--id <id> | --all) --file <document> [--workspace <root>] [--check]
103
+ oa-sync-template docs-region --list
104
+
105
+ template renders the packaged template into a directory with its own
106
+ placeholders as the parameters, which is what api/templates/business-service
107
+ is: generated output, committed for reading (001 §5). --check is the gate.
108
+
109
+ With bare paths (or none, meaning all of them) the run rewrites the files the
110
+ manifest declares in the classes "identical" and "generated", from the same
111
+ reference the conformance check reads. Files of the "own" class - src/**,
112
+ tests/**, the content of docs/** - are never written. A row the manifest does
113
+ not make renderable is printed NOT RUN with the reason.
114
+
115
+ --new <name> create a service tree from the packaged template
116
+ --into <dir> where that tree is written; it must not exist yet
117
+ --description <s> what the service is for; without it the template's
118
+ __SERVICE_DESCRIPTION__ is LEFT standing and the files
119
+ carrying it are listed
120
+
121
+ Writes <dir>/config/env-templates/shared.env from the platform env manifest
122
+ api/config/shared-env.json — the one owner of the shared key set. A service
123
+ adds keys of its own in service.env, never here.
124
+
125
+ --target <dir> the bearer: a service root, api/ itself, or the business
126
+ service template
127
+ --workspace <root> the directory holding api/ and api_biz/; found upwards
128
+ from --target when not given
129
+ --check write nothing; exit 1 with a diff when the file on disk is
130
+ not what the manifest renders
131
+
132
+ readme-uniform writes the generated region of a repository's README.md from
133
+ the manifest that ships with this package. --target reads the kind from the
134
+ disk: a root carrying config/service/operations.json is a SERVICE and gets
135
+ "Uniform: [biz-service](<link>)", the sections of the biz-service manifest and
136
+ which path falls under which row; a root carrying package.json is a LIBRARY and
137
+ gets "Uniform: [library/<category>](<link>)" and the duty sections its declared
138
+ category wears. A library that declares no "oa.category" is reported NOT RUN
139
+ under --all and never guessed at.
140
+
141
+ --all is the library run: the eight service repositories are separate
142
+ checkouts, not siblings under one workspace, so there is no list to walk - a
143
+ service is written one --target at a time. A service needs no workspace above
144
+ it either; its region is the packaged manifest and its link is the copy npm
145
+ installs into its own node_modules.
146
+
147
+ --target <dir> one library directory or one service root
148
+ --all every library the manifest discovers under the workspace
149
+ --workspace <root> the directory holding api/ and api_biz/; found upwards
150
+ from --target when not given
151
+ --check write nothing; exit 1 with a diff when a region on disk is
152
+ not what the manifest renders
153
+
154
+ docs-region writes the descriptive lists of the documentation tree - the
155
+ repository shape, the scripts, the runtime numbers, the row table and the
156
+ library duties - from the two packaged manifests (002 §16.2). It REPLACES a
157
+ region between markers and never inserts one: those documents belong to other
158
+ trees, and where a section goes in them is their owner's decision. A document
159
+ carrying no markers is a failure that prints the two lines to paste.
160
+
161
+ --id <id> one region; --list names them all
162
+ --all every region whose markers the document carries
163
+ --file <document> the document to write, which its markers name
164
+ --list the region ids and what each renders from
165
+ --check write nothing; exit 1 with a diff when a region is stale
166
+
167
+ Exit: 0 written / in sync | 1 drifted or missing under --check | 2 the run could
168
+ not start.
169
+ `;
170
+
171
+ /**
172
+ * The named runs. A positional argument is a subcommand ONLY as the first one
173
+ * and ONLY when it matches this list exactly; every other positional is a
174
+ * manifest path. Stated rather than inferred, because "it happens not to
175
+ * collide" is not a rule (`.claude/rules/architecture-principles.md` §8).
176
+ */
177
+ const SUBCOMMAND_NAMES = Object.freeze(['shared-env', 'readme-uniform', 'template', 'docs-region']);
178
+
179
+ /** Options that take a directory, and the field each fills. */
180
+ const DIRECTORY_OPTIONS = Object.freeze({
181
+ '--target': 'target',
182
+ '--workspace': 'workspace',
183
+ '--into': 'into'
184
+ });
185
+
186
+ /** Options that take a value which is not a directory, and the field each fills. */
187
+ const VALUE_OPTIONS = Object.freeze({
188
+ '--new': 'newName',
189
+ '--description': 'description',
190
+ '--id': 'id',
191
+ '--file': 'file'
192
+ });
193
+
194
+ /** What a value option is called in its own error message. */
195
+ const VALUE_NAMES = Object.freeze({
196
+ '--new': 'name',
197
+ '--description': 'text',
198
+ '--id': 'region id',
199
+ '--file': 'document'
200
+ });
201
+
202
+ function parseArgs(argv) {
203
+ const options = {
204
+ command: null,
205
+ paths: [],
206
+ target: null,
207
+ into: null,
208
+ workspace: null,
209
+ newName: null,
210
+ description: null,
211
+ id: null,
212
+ file: null,
213
+ check: false,
214
+ all: false,
215
+ list: false
216
+ };
217
+
218
+ for (let index = 0; index < argv.length; index += 1) {
219
+ const arg = argv[index];
220
+ if (arg === '--check') {
221
+ options.check = true;
222
+ } else if (arg === '--all') {
223
+ options.all = true;
224
+ } else if (arg === '--list') {
225
+ options.list = true;
226
+ } else if (DIRECTORY_OPTIONS[arg] !== undefined) {
227
+ const value = argv[index + 1];
228
+ if (value === undefined || value.startsWith('--')) {
229
+ throw new Error(`[oa-sync-template] ${arg} needs a directory - none followed it. `
230
+ + `Fix: ${arg} <dir>.`);
231
+ }
232
+ options[DIRECTORY_OPTIONS[arg]] = value;
233
+ index += 1;
234
+ } else if (VALUE_OPTIONS[arg] !== undefined) {
235
+ const value = argv[index + 1];
236
+ if (value === undefined || value.startsWith('--')) {
237
+ throw new Error(`[oa-sync-template] ${arg} needs a value - none followed it. `
238
+ + `Fix: ${arg} <${VALUE_NAMES[arg]}>.`);
239
+ }
240
+ options[VALUE_OPTIONS[arg]] = value;
241
+ index += 1;
242
+ } else if (arg.startsWith('--')) {
243
+ throw new Error(`[oa-sync-template] Unknown option "${arg}". Fix: see oa-sync-template with no arguments.`);
244
+ } else if (options.command === null && options.paths.length === 0 && SUBCOMMAND_NAMES.includes(arg)) {
245
+ options.command = arg;
246
+ } else if (options.command !== null) {
247
+ throw new Error(`[oa-sync-template] Unexpected argument "${arg}" after the subcommand `
248
+ + `"${options.command}" - a subcommand takes no paths. `
249
+ + 'Fix: see oa-sync-template with no arguments.');
250
+ } else {
251
+ options.paths.push(arg);
252
+ }
253
+ }
254
+
255
+ return options;
256
+ }
257
+
258
+ function loadManifest(workspaceRoot) {
259
+ const manifestPath = path.join(workspaceRoot, MANIFEST_RELATIVE);
260
+ if (!fs.existsSync(manifestPath)) {
261
+ throw new Error(`[oa-sync-template] Missing platform env manifest - ${manifestPath} does not exist. `
262
+ + 'Fix: the shared key set is declared there and nowhere else.');
263
+ }
264
+
265
+ try {
266
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
267
+ } catch (error) {
268
+ throw new Error(`[oa-sync-template] Invalid platform env manifest - ${manifestPath}: ${error.message}`);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * @returns {number} process exit code
274
+ */
275
+ function runSharedEnv(options) {
276
+ if (options.target === null) {
277
+ throw new Error('[oa-sync-template] Missing --target - the run has to be told which bearer to write. '
278
+ + 'Fix: oa-sync-template shared-env --target <dir>.');
279
+ }
280
+
281
+ const target = path.resolve(options.target);
282
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, startDir: target });
283
+ if (workspaceRoot === null) {
284
+ throw new Error(`[oa-sync-template] Workspace root not found - no ${WORKSPACE_MARKER} above ${target}. `
285
+ + 'Fix: pass --workspace <root> pointing at the directory that holds api/ and api_biz/.');
286
+ }
287
+
288
+ const manifest = loadManifest(workspaceRoot);
289
+ const file = path.join(target, SHARED_ENV_RELATIVE);
290
+
291
+ if (options.check) {
292
+ if (!fs.existsSync(file)) {
293
+ process.stderr.write(`[oa-sync-template] shared-env is missing - ${file}. `
294
+ + `Fix: oa-sync-template shared-env --target ${target}\n`);
295
+ return 1;
296
+ }
297
+
298
+ const result = checkSharedEnv(manifest, fs.readFileSync(file, 'utf8'));
299
+ if (result.ok) {
300
+ process.stdout.write(`[oa-sync-template] shared-env is in sync - ${file}\n`);
301
+ return 0;
302
+ }
303
+
304
+ process.stderr.write(`[oa-sync-template] shared-env drifted from the manifest - ${file}\n`
305
+ + `${result.diff}\n`
306
+ + `Fix: oa-sync-template shared-env --target ${target}\n`);
307
+ return 1;
308
+ }
309
+
310
+ const text = renderSharedEnv(manifest);
311
+ fs.mkdirSync(path.dirname(file), { recursive: true });
312
+ fs.writeFileSync(file, text);
313
+ process.stdout.write(`[oa-sync-template] wrote ${manifest.keys.length} keys - ${file}\n`);
314
+ return 0;
315
+ }
316
+
317
+ /**
318
+ * The parsed `package.json` of a directory. A directory without one is not a
319
+ * library, and the run says so instead of rendering a region for nothing.
320
+ *
321
+ * @param {string} dir
322
+ * @returns {object}
323
+ */
324
+ function readPackage(dir) {
325
+ const file = path.join(dir, 'package.json');
326
+ if (!fs.existsSync(file)) {
327
+ throw new Error(`[oa-sync-template] Not a package - ${file} does not exist. `
328
+ + 'Fix: point --target at a library directory that carries package.json.');
329
+ }
330
+ try {
331
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
332
+ } catch (cause) {
333
+ throw new Error(`[oa-sync-template] Invalid package.json - ${file}: ${cause.message}`);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * What makes a root a SERVICE: the operations file the platform reads at boot,
339
+ * which row `C-OPS` of the biz-service manifest owns. A library is what carries
340
+ * a `package.json` and no such file.
341
+ *
342
+ * The kind is read from the disk rather than passed in. A `--kind` flag would be
343
+ * a second way to say a thing the directory already says, and the two would
344
+ * eventually disagree (`.claude/rules/automation-gates.md` §1 requirement 5).
345
+ * The order matters and is stated rather than incidental: a service root carries
346
+ * a `package.json` too, so the service marker is looked for first.
347
+ */
348
+ const SERVICE_MARKER = path.join('config', 'service', 'operations.json');
349
+
350
+ /** What each kind's README is, when the run finds none. */
351
+ const README_FIX = Object.freeze({
352
+ [KINDS.library]: `Fix: give the package a ${README_FILE} carrying the node header `
353
+ + '(row L-README of the library manifest).',
354
+ [KINDS.service]: `Fix: give the service repository a ${README_FILE} - the uniform pointer is a region of it.`
355
+ });
356
+
357
+ /**
358
+ * @param {string} dir an absolute root
359
+ * @returns {string} one of KINDS
360
+ */
361
+ function detectKind(dir) {
362
+ if (fs.existsSync(path.join(dir, SERVICE_MARKER))) return KINDS.service;
363
+ if (fs.existsSync(path.join(dir, 'package.json'))) return KINDS.library;
364
+ throw new Error(`[oa-sync-template] ${dir} is neither a service nor a package - a service root carries `
365
+ + 'config/service/operations.json, a library root carries package.json, and this run reads the kind '
366
+ + 'from the disk rather than being told it. '
367
+ + 'Fix: point --target at a service repository or a library directory.');
368
+ }
369
+
370
+ /**
371
+ * One README, written or checked. Both kinds go through it, so the two runs
372
+ * cannot drift into two vocabularies of the same result.
373
+ *
374
+ * @returns {string|null} the drift report, or null when the file is in sync
375
+ */
376
+ function syncOneReadme({ dir, label, region, kind, check }) {
377
+ const file = path.join(dir, README_FILE);
378
+ if (!fs.existsSync(file)) {
379
+ throw new Error(`[oa-sync-template] ${label} has no ${README_FILE} - the uniform pointer `
380
+ + `is a region of that file. ${README_FIX[kind]}`);
381
+ }
382
+
383
+ const text = fs.readFileSync(file, 'utf8');
384
+
385
+ if (check) {
386
+ const result = checkUniformRegion(text, region, { kind });
387
+ if (result.ok) {
388
+ process.stdout.write(`[oa-sync-template] readme-uniform is in sync - ${label}/${README_FILE}\n`);
389
+ return null;
390
+ }
391
+ return `[oa-sync-template] readme-uniform drifted from the manifest - `
392
+ + `${label}/${README_FILE}\n${result.diff}\n`;
393
+ }
394
+
395
+ const applied = applyUniformRegion(text, region, { kind });
396
+ if (applied === text) {
397
+ process.stdout.write(`[oa-sync-template] readme-uniform unchanged - ${label}/${README_FILE}\n`);
398
+ } else {
399
+ fs.writeFileSync(file, applied);
400
+ process.stdout.write(`[oa-sync-template] readme-uniform wrote ${label}/${README_FILE}\n`);
401
+ }
402
+ return null;
403
+ }
404
+
405
+ /**
406
+ * `readme-uniform` - the generated uniform pointer of a library README
407
+ * (confirmation `biz-service-manifest` 005 point 2).
408
+ *
409
+ * A library that declares no category is NOT RUN under `--all`, named on stdout,
410
+ * and counted out of the coverage line. It is not silently excused and it is not
411
+ * guessed at: the missing declaration is already the blocking finding
412
+ * `U-MISMATCH` of `oa-validate --library`, and a second mechanism reporting the
413
+ * same fact would be a second rail for one concern
414
+ * (`.claude/rules/change-discipline.md` § One rail per concern;
415
+ * `.claude/rules/automation-gates.md` §5 - what a run did not cover is said out
416
+ * loud, never passed over in silence). Asked about that package BY NAME, the run
417
+ * fails fast instead, because then the answer is the whole result.
418
+ *
419
+ * @returns {number} process exit code
420
+ */
421
+ function runReadmeUniform(options) {
422
+ if (options.target === null && !options.all) {
423
+ throw new Error('[oa-sync-template] Missing --target - the run has to be told which library to write, '
424
+ + 'or --all for every library the manifest discovers. '
425
+ + 'Fix: oa-sync-template readme-uniform --target <dir>.');
426
+ }
427
+ if (options.target !== null && options.all) {
428
+ throw new Error('[oa-sync-template] --target and --all are exclusive - a run writes one library or '
429
+ + 'every library, never both. Fix: drop one of them.');
430
+ }
431
+
432
+ // The target is canonicalised once, here: the region's link is
433
+ // `path.relative(target, manifestPath)` and the manifest path is derived from
434
+ // the workspace root, which resolves through its symlinks. Two spellings of
435
+ // one directory made that link climb out of the workspace and back in
436
+ // (`workspaceRoot.js` § canonicalRoot).
437
+ const target = options.all ? null : canonicalRoot(options.target);
438
+
439
+ if (!options.all && detectKind(target) === KINDS.service) {
440
+ return runServiceReadme(target, options);
441
+ }
442
+ return runLibraryReadme({ ...options, target });
443
+ }
444
+
445
+ /**
446
+ * `readme-uniform --target <service>` - the uniform pointer of a biz service.
447
+ *
448
+ * The link points at the manifest as it lies in the service AFTER `npm install`:
449
+ * `node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json`,
450
+ * the copy the pin decides and the one `oa-validate` reads there. It is derived
451
+ * from this package's own name and the manifest's position inside it, never
452
+ * typed, and it is deliberately a path rather than a URL - a reader of the
453
+ * repository resolves it without a network.
454
+ *
455
+ * No workspace is resolved and none is required: a service repository is its own
456
+ * checkout, and the region's whole content is the packaged manifest. The
457
+ * workspace is consulted for one thing only - what to call the target on stdout -
458
+ * and its absence is not an error.
459
+ *
460
+ * @returns {number} process exit code
461
+ */
462
+ function runServiceReadme(target, options) {
463
+ const region = serviceRegion(target);
464
+
465
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, startDir: target });
466
+ const label = workspaceRoot === null
467
+ ? target
468
+ : path.relative(workspaceRoot, target).split(path.sep).join('/');
469
+
470
+ const drift = syncOneReadme({
471
+ dir: target, label, region, kind: KINDS.service, check: options.check
472
+ });
473
+
474
+ if (drift !== null) {
475
+ process.stderr.write(`${drift}Fix: npx oa-sync-template readme-uniform --target ${target}\n`);
476
+ return 1;
477
+ }
478
+ return 0;
479
+ }
480
+
481
+ /**
482
+ * `readme-uniform` over the libraries of one workspace - one named by `--target`,
483
+ * or every one the manifest's discovery block finds under `--all`.
484
+ *
485
+ * @returns {number} process exit code
486
+ */
487
+ function runLibraryReadme(options) {
488
+ const target = options.target;
489
+ const workspaceRoot = resolveWorkspaceRoot({
490
+ explicit: options.workspace,
491
+ startDir: target === null ? PACKAGE_ROOT : target
492
+ });
493
+ if (workspaceRoot === null) {
494
+ throw new Error(`[oa-sync-template] Workspace root not found - no ${WORKSPACE_MARKER} above `
495
+ + `${target === null ? PACKAGE_ROOT : target}. `
496
+ + 'Fix: pass --workspace <root> pointing at the directory that holds api/ and api_biz/.');
497
+ }
498
+
499
+ const manifest = loadLibraryManifest(LIBRARY_MANIFEST_PATH);
500
+ const block = manifest.discovery && manifest.discovery.library;
501
+ if (!block) {
502
+ throw new Error('[oa-sync-template] Library manifest declares no discovery.library block - the run finds '
503
+ + 'the libraries from it and knows no list of its own. '
504
+ + `Fix: repair ${LIBRARY_MANIFEST_PATH}.`);
505
+ }
506
+
507
+ const { bearers } = discoverBearers({ block, workspaceRoot });
508
+
509
+ const relativeOf = (dir) => path.relative(workspaceRoot, dir).split(path.sep).join('/');
510
+ const packages = options.all
511
+ ? bearers.map((bearer) => ({ dir: bearer.dir, relativeDir: bearer.relativeDir }))
512
+ : [{ dir: target, relativeDir: relativeOf(target) }];
513
+
514
+ const drifted = [];
515
+ let covered = 0;
516
+ let notRun = 0;
517
+
518
+ for (const entry of packages) {
519
+ const pkg = readPackage(entry.dir);
520
+
521
+ if (declaredCategory(pkg) === null) {
522
+ if (!options.all) {
523
+ throw new Error(`[oa-sync-template] ${entry.relativeDir} declares no "oa"."category" - the layer a `
524
+ + 'package is meant to sit in cannot be derived from anything else, so the pointer cannot be '
525
+ + 'rendered. Fix: declare "oa": { "category": "<core|connector|orchestration|runtime|tooling>" } '
526
+ + 'in package.json.');
527
+ }
528
+ process.stdout.write(`[oa-sync-template] readme-uniform NOT RUN - ${entry.relativeDir} declares no `
529
+ + '"oa"."category"; that is finding U-MISMATCH of oa-validate --library, and this run never '
530
+ + 'guesses one\n');
531
+ notRun += 1;
532
+ continue;
533
+ }
534
+
535
+ const region = libraryRegion({ packageDir: entry.dir, pkg, workspaceRoot, manifest });
536
+ covered += 1;
537
+
538
+ const drift = syncOneReadme({
539
+ dir: entry.dir,
540
+ label: entry.relativeDir,
541
+ region,
542
+ kind: KINDS.library,
543
+ check: options.check
544
+ });
545
+ if (drift !== null) drifted.push(drift);
546
+ }
547
+
548
+ if (options.all) {
549
+ process.stdout.write(`[oa-sync-template] readme-uniform covered ${covered} of ${covered + notRun} libraries\n`);
550
+ }
551
+
552
+ if (drifted.length > 0) {
553
+ const fix = options.all ? '--all' : `--target ${target}`;
554
+ process.stderr.write(`${drifted.join('')}Fix: npx oa-sync-template readme-uniform ${fix}\n`);
555
+ return 1;
556
+ }
557
+ return 0;
558
+ }
559
+
560
+ /**
561
+ * `docs-region` - a generated region of the documentation tree, rendered from
562
+ * the two packaged manifests (confirmation `biz-service-manifest` 002 §16.2).
563
+ *
564
+ * The run REPLACES a region and never inserts one. The documents live in
565
+ * `api/docs/**`, a tree owned by other threads: deciding on their behalf where a
566
+ * section belongs would be the unsafe side effect `automation-gates.md` §1
567
+ * requirement 3 forbids, and a marker nobody put there is a section nobody
568
+ * agreed to. So a document with no markers is a failure that prints the two
569
+ * lines to paste, and `--all` covers exactly the regions a document already
570
+ * declares.
571
+ *
572
+ * @returns {number} process exit code
573
+ */
574
+ function runDocsRegion(options) {
575
+ if (options.list) {
576
+ process.stdout.write('[oa-sync-template] docs-region renders, from the manifests this package ships:\n');
577
+ for (const id of docsRegion.REGION_IDS) {
578
+ process.stdout.write(` ${id} - ${docsRegion.describeRegion(id)}\n`);
579
+ }
580
+ return 0;
581
+ }
582
+
583
+ if (options.file === null) {
584
+ throw new Error('[oa-sync-template] Missing --file - the run has to be told which document to write, '
585
+ + 'and the region carries that path in its own marker. '
586
+ + 'Fix: oa-sync-template docs-region --id <id> --file <document>, or --list to see the ids.');
587
+ }
588
+ if (options.id === null && !options.all) {
589
+ throw new Error('[oa-sync-template] Missing --id - the run writes one named region, or --all for every '
590
+ + 'region the document already declares. '
591
+ + 'Fix: oa-sync-template docs-region --id <id> --file <document>, or --list to see the ids.');
592
+ }
593
+ if (options.id !== null && options.all) {
594
+ throw new Error('[oa-sync-template] --id and --all are exclusive - a run writes one region or every '
595
+ + 'region the document declares, never both. Fix: drop one of them.');
596
+ }
597
+
598
+ const given = path.resolve(options.file);
599
+ if (!fs.existsSync(given)) {
600
+ throw new Error(`[oa-sync-template] Document does not exist - ${given}. `
601
+ + 'Fix: point --file at the document that carries the markers.');
602
+ }
603
+ // The document's own directory is canonicalised, so the label — and with it
604
+ // the marker the region is found by — is relative to the workspace root in
605
+ // the same spelling (`workspaceRoot.js` § canonicalRoot).
606
+ const file = path.join(canonicalRoot(path.dirname(given)), path.basename(given));
607
+
608
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, startDir: path.dirname(file) });
609
+ if (workspaceRoot === null) {
610
+ throw new Error(`[oa-sync-template] Workspace root not found - no ${WORKSPACE_MARKER} above ${file}. `
611
+ + 'Fix: pass --workspace <root> pointing at the directory that holds api/ and api_biz/.');
612
+ }
613
+
614
+ const targetLabel = path.relative(workspaceRoot, file).split(path.sep).join('/');
615
+ const serviceManifest = loadUniformManifest(DEFAULT_MANIFEST_PATH);
616
+ const libraryManifest = loadLibraryManifest(LIBRARY_MANIFEST_PATH);
617
+
618
+ const block = libraryManifest.discovery && libraryManifest.discovery.library;
619
+ if (!block) {
620
+ throw new Error('[oa-sync-template] Library manifest declares no discovery.library block - the run finds '
621
+ + 'this package through it to compute the link the region carries. '
622
+ + `Fix: repair ${LIBRARY_MANIFEST_PATH}.`);
623
+ }
624
+ const { bearers } = discoverBearers({ block, workspaceRoot });
625
+ const render = {
626
+ serviceManifest,
627
+ libraryManifest,
628
+ serviceManifestPath: manifestInWorkspace({
629
+ workspaceRoot, relativeInPackage: SERVICE_MANIFEST_IN_PACKAGE
630
+ }),
631
+ libraryManifestPath: manifestInWorkspace({
632
+ workspaceRoot, relativeInPackage: LIBRARY_MANIFEST_IN_PACKAGE
633
+ }),
634
+ targetPath: file,
635
+ targetLabel,
636
+ templateFiles: listTemplateFiles(TEMPLATE_ROOT).map(outputRelative)
637
+ };
638
+
639
+ let text = fs.readFileSync(file, 'utf8');
640
+ const ids = options.all ? docsRegion.regionIdsIn(text, targetLabel) : [options.id];
641
+
642
+ if (ids.length === 0) {
643
+ throw new Error(`[oa-sync-template] ${targetLabel} carries no generated region of this run - docs-region `
644
+ + 'replaces a region and never decides where one belongs in a document it does not own. '
645
+ + `Fix: put the markers of one of ${docsRegion.REGION_IDS.join(', ')} where the region belongs `
646
+ + '(oa-sync-template docs-region --list names what each renders), then run this again.');
647
+ }
648
+
649
+ const drifted = [];
650
+ let changed = false;
651
+
652
+ for (const id of ids) {
653
+ const region = docsRegion.renderRegion(id, render);
654
+
655
+ if (options.check) {
656
+ const result = docsRegion.checkRegion(text, id, region, { targetLabel });
657
+ if (result.ok) {
658
+ process.stdout.write(`[oa-sync-template] docs-region is in sync - ${targetLabel} (${id})\n`);
659
+ } else {
660
+ drifted.push(`[oa-sync-template] docs-region drifted from the manifest - ${targetLabel} (${id})\n`
661
+ + `${result.diff}\n`);
662
+ }
663
+ continue;
664
+ }
665
+
666
+ const applied = docsRegion.applyRegion(text, id, region, { targetLabel });
667
+ if (applied === text) {
668
+ process.stdout.write(`[oa-sync-template] docs-region unchanged - ${targetLabel} (${id})\n`);
669
+ } else {
670
+ text = applied;
671
+ changed = true;
672
+ process.stdout.write(`[oa-sync-template] docs-region wrote ${id} - ${targetLabel}\n`);
673
+ }
674
+ }
675
+
676
+ if (changed) fs.writeFileSync(file, text);
677
+
678
+ if (drifted.length > 0) {
679
+ process.stderr.write(`${drifted.join('')}Fix: npx oa-sync-template docs-region `
680
+ + `${options.all ? '--all' : `--id ${options.id}`} --file ${targetLabel}\n`);
681
+ return 1;
682
+ }
683
+ return 0;
684
+ }
685
+
686
+ /**
687
+ * Where the workspace root is, for a run that was given a service root.
688
+ *
689
+ * @param {{explicit: string|null, startDir: string}} params
690
+ * @returns {string}
691
+ */
692
+ function requireWorkspaceRoot({ explicit, startDir }) {
693
+ const workspaceRoot = resolveWorkspaceRoot({ explicit, startDir });
694
+ if (workspaceRoot === null) {
695
+ throw new Error(`[oa-sync-template] Workspace root not found - no ${WORKSPACE_MARKER} above ${startDir}. `
696
+ + 'Fix: pass --workspace <root> pointing at the directory that holds api/ and api_biz/.');
697
+ }
698
+ return workspaceRoot;
699
+ }
700
+
701
+ /**
702
+ * Write one file of a service tree, creating the directories it needs.
703
+ *
704
+ * `init.sh` is the one file written executable: it is the container entrypoint,
705
+ * and a template file cannot carry the bit through `npm pack`
706
+ * (`EXECUTABLE_FILES` is where that fact lives).
707
+ */
708
+ function writeServiceFile(serviceRoot, relative, text) {
709
+ const file = path.join(serviceRoot, ...relative.split('/'));
710
+ fs.mkdirSync(path.dirname(file), { recursive: true });
711
+ fs.writeFileSync(file, text);
712
+ if (EXECUTABLE_FILES.includes(relative)) fs.chmodSync(file, 0o755);
713
+ }
714
+
715
+ /**
716
+ * `oa-sync-template [path...]` - the uniform sync (confirmation 001 §3.2).
717
+ *
718
+ * @returns {number} process exit code
719
+ */
720
+ function runSync(options) {
721
+ // The positionals are judged FIRST, against the manifest, and before anything
722
+ // that touches the disk: a mistyped subcommand must be named as what it is,
723
+ // not reported as a missing workspace three steps later.
724
+ const declared = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH)).map((row) => row.path);
725
+ for (const wanted of options.paths) {
726
+ if (declared.includes(wanted)) continue;
727
+ throw new Error(`[oa-sync-template] Unknown argument "${wanted}" - it is neither a subcommand this run `
728
+ + `has (${SUBCOMMAND_NAMES.join(', ')}) nor a path the uniform declares (${declared.join(', ')}). `
729
+ + 'Fix: name one of those, or leave the paths out to write them all.');
730
+ }
731
+
732
+ if (options.target === null) {
733
+ throw new Error('[oa-sync-template] Missing --target - the run has to be told which service to write. '
734
+ + 'Fix: oa-sync-template [path...] --target <serviceRoot>.');
735
+ }
736
+
737
+ const target = path.resolve(options.target);
738
+ const manifest = loadUniformManifest(DEFAULT_MANIFEST_PATH);
739
+
740
+ // A workspace is not a precondition of this run, it is a precondition of the
741
+ // ROWS that read a workspace file — and since d.229 most of them read a file
742
+ // this package carries instead. Demanding one up front made the `fix` command
743
+ // of F-INIT, F-JEST and F-RUNNER unrunnable in the one place their finding is
744
+ // now raised: inside the service image. A row that does need it is reported
745
+ // NOT RUN by name, never silently skipped (`automation-gates.md` §5).
746
+ const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, startDir: target });
747
+ const entries = planSync({ manifest, serviceRoot: target, workspaceRoot, paths: options.paths });
748
+
749
+ let changed = 0;
750
+ let blocked = 0;
751
+
752
+ for (const entry of entries) {
753
+ const where = `${entry.id} ${entry.path}`;
754
+ if (entry.outcome === 'not-run') {
755
+ process.stdout.write(`[oa-sync-template] NOT RUN ${where} - ${entry.reason}\n`);
756
+ } else if (entry.outcome === 'blocked') {
757
+ blocked += 1;
758
+ process.stderr.write(`[oa-sync-template] BLOCKED ${where} - ${entry.reason}\n`);
759
+ } else if (entry.outcome === 'unchanged') {
760
+ process.stdout.write(`[oa-sync-template] unchanged ${where}\n`);
761
+ } else if (options.check) {
762
+ changed += 1;
763
+ process.stdout.write(`[oa-sync-template] would change ${where} - ${entry.detail}\n`);
764
+ } else {
765
+ changed += 1;
766
+ writeServiceFile(target, entry.path, entry.desired);
767
+ process.stdout.write(`[oa-sync-template] wrote ${where} - ${entry.detail}\n`);
768
+ }
769
+ }
770
+
771
+ if (options.check && changed > 0) {
772
+ process.stderr.write(`[oa-sync-template] ${changed} file(s) would change - ${target}\n`
773
+ + `Fix: npx oa-sync-template --target ${target}\n`);
774
+ }
775
+ return changed > 0 && options.check ? 1 : (blocked > 0 ? 1 : 0);
776
+ }
777
+
778
+ /**
779
+ * The platform SSOT of library versions.
780
+ *
781
+ * @param {string} workspaceRoot
782
+ * @returns {object} the parsed `libraries` map
783
+ */
784
+ function loadLibraries(workspaceRoot) {
785
+ const file = path.join(workspaceRoot, LIBRARIES_RELATIVE);
786
+ if (!fs.existsSync(file)) {
787
+ throw new Error(`[oa-sync-template] Missing library SSOT - ${file} does not exist. `
788
+ + 'Fix: a new service pins every @onlineapps dependency to the platform SSOT, which is that file.');
789
+ }
790
+ let parsed;
791
+ try {
792
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
793
+ } catch (cause) {
794
+ throw new Error(`[oa-sync-template] Invalid library SSOT - ${file}: ${cause.message}`);
795
+ }
796
+ if (parsed === null || typeof parsed.libraries !== 'object' || parsed.libraries === null) {
797
+ throw new Error(`[oa-sync-template] Library SSOT declares no "libraries" map - ${file}. `
798
+ + 'Fix: the pins are read from that key and from nowhere else.');
799
+ }
800
+ return parsed.libraries;
801
+ }
802
+
803
+ /**
804
+ * The service's `package.json` with every `@onlineapps/*` pin taken from the
805
+ * platform SSOT rather than from whatever version the template was last touched
806
+ * with (`.claude/rules/architecture-principles.md` § Version pinning).
807
+ *
808
+ * The template does not state a version at all: it writes `SSOT_PIN`, and this
809
+ * is the one place that resolves it. A dependency the SSOT does not know FAILS
810
+ * the run - guessing a version, or leaving the template's, is how a service ends
811
+ * up installing code the platform never proved.
812
+ *
813
+ * The pass ends by refusing a placeholder it did not resolve, which can only be
814
+ * one written OUTSIDE the platform scope: no SSOT owns that name, so nothing
815
+ * here can fill it in, and the alternative to stopping is a `package.json`
816
+ * carrying a version npm cannot parse - discovered at the first `npm ci` rather
817
+ * than at the run that wrote it (`.claude/rules/architecture-principles.md` §4).
818
+ *
819
+ * @param {string} text rendered package.json
820
+ * @param {object} libraries the SSOT map
821
+ * @returns {string}
822
+ */
823
+ function pinLibraries(text, libraries) {
824
+ const pkg = JSON.parse(text);
825
+ for (const field of ['dependencies', 'devDependencies']) {
826
+ const block = pkg[field];
827
+ if (block === undefined) continue;
828
+ for (const name of Object.keys(block)) {
829
+ if (!name.startsWith(PINNED_SCOPE)) continue;
830
+ const version = libraries[name];
831
+ if (typeof version !== 'string') {
832
+ throw new Error(`[oa-sync-template] Missing library version - "${name}" has no entry in `
833
+ + `${LIBRARIES_RELATIVE}. Fix: add it to the platform SSOT, or drop the dependency from the `
834
+ + 'business-service template.');
835
+ }
836
+ block[name] = version;
837
+ }
838
+ }
839
+
840
+ for (const field of ['dependencies', 'devDependencies']) {
841
+ const block = pkg[field];
842
+ if (block === undefined) continue;
843
+ for (const [name, version] of Object.entries(block)) {
844
+ if (typeof version === 'string' && version.includes(SSOT_PIN)) {
845
+ throw new Error(`[oa-sync-template] Unresolved ${SSOT_PIN} - "${name}" asks for a platform pin, `
846
+ + `but ${LIBRARIES_RELATIVE} owns the versions of ${PINNED_SCOPE}* and of nothing else, so `
847
+ + 'nothing can fill it in. Fix: give that dependency its exact version in the business-service '
848
+ + `template, or move it under ${PINNED_SCOPE}.`);
849
+ }
850
+ }
851
+ }
852
+ return `${JSON.stringify(pkg, null, 2)}\n`;
853
+ }
854
+
855
+ /**
856
+ * `oa-sync-template --new <name> --into <dir>` - a whole service tree
857
+ * (confirmation 001 §3.2, and §5: this is what `api/templates/business-service`
858
+ * is the output of).
859
+ *
860
+ * Three things happen to the rendered tree, and each is here because it is a
861
+ * fact about ONE service rather than about the shape:
862
+ * * the env template is named after the service, the way every live repo has it;
863
+ * * `shared.env` comes from the platform env manifest, its one owner (003 §18);
864
+ * * every `@onlineapps` pin comes from the platform SSOT.
865
+ *
866
+ * @returns {number} process exit code
867
+ */
868
+ function runNew(options) {
869
+ if (options.into === null) {
870
+ throw new Error('[oa-sync-template] Missing --into - the run has to be told where the service tree goes. '
871
+ + 'Fix: oa-sync-template --new <name> --into <serviceRoot>.');
872
+ }
873
+ if (options.command !== null || options.paths.length > 0 || options.all || options.check) {
874
+ throw new Error('[oa-sync-template] --new creates a service and takes no subcommand, path, --all or '
875
+ + '--check - there is nothing on disk yet to compare against. Fix: run --new on its own.');
876
+ }
877
+
878
+ const params = deriveParams({ name: options.newName, description: options.description });
879
+ const into = path.resolve(options.into);
880
+ if (fs.existsSync(into)) {
881
+ throw new Error(`[oa-sync-template] ${into} already exists - a service tree is written into a directory `
882
+ + 'that does not exist yet, so nothing anybody else wrote is overwritten. '
883
+ + 'Fix: remove it, or pass --into <another directory>.');
884
+ }
885
+
886
+ const workspaceRoot = requireWorkspaceRoot({ explicit: options.workspace, startDir: path.dirname(into) });
887
+ const envManifest = loadManifest(workspaceRoot);
888
+ const libraries = loadLibraries(workspaceRoot);
889
+
890
+ const tree = renderTree({ params });
891
+
892
+ const envTemplate = tree.get(ENV_TEMPLATE_SOURCE);
893
+ tree.delete(ENV_TEMPLATE_SOURCE);
894
+ tree.set(envTemplateTarget(params.service_name), envTemplate);
895
+
896
+ tree.set(SHARED_ENV_POSIX, renderSharedEnv(envManifest));
897
+ tree.set('package.json', pinLibraries(tree.get('package.json'), libraries));
898
+
899
+ for (const [relative, text] of tree) writeServiceFile(into, relative, text);
900
+
901
+ process.stdout.write(`[oa-sync-template] --new wrote ${tree.size} files - ${into}\n`);
902
+
903
+ if (params.description === null) {
904
+ const pending = [...tree.entries()]
905
+ .filter(([, text]) => text.includes(PLACEHOLDERS.description))
906
+ .map(([relative]) => relative);
907
+ process.stdout.write(`[oa-sync-template] ${PLACEHOLDERS.description} is still in place - this run was `
908
+ + `given no description. Files carrying it: ${pending.join(', ')}\n`);
909
+ }
910
+ return 0;
911
+ }
912
+
913
+ /**
914
+ * `oa-sync-template template --target <dir>` - the template as GENERATED OUTPUT
915
+ * (confirmation `biz-service-manifest` 001 §5).
916
+ *
917
+ * `api/templates/business-service` stopped being hand-maintained the day the
918
+ * template moved into this package. What it is now is this run's output: the
919
+ * renderer with `IDENTITY_PARAMS`, which substitutes every placeholder by
920
+ * itself. That is what makes §9's acceptance measurable instead of felt - the
921
+ * directory differs from the generator's output by nothing, and `--check` says
922
+ * so with an exit code.
923
+ *
924
+ * It takes no workspace and no parameters: there is no service here to name.
925
+ *
926
+ * @returns {number} process exit code
927
+ */
928
+ function runTemplate(options) {
929
+ if (options.target === null) {
930
+ throw new Error('[oa-sync-template] Missing --target - the run has to be told where the rendered '
931
+ + 'template goes. Fix: oa-sync-template template --target api/templates/business-service.');
932
+ }
933
+
934
+ const target = path.resolve(options.target);
935
+ const tree = renderTree({ params: IDENTITY_PARAMS });
936
+ const drifted = [];
937
+ let written = 0;
938
+
939
+ // The template's own `.gitignore` says which paths of a rendered tree are
940
+ // generated output, and this comparison reads that rule on BOTH sides: a
941
+ // source file under such a path would be a file no service could ever commit,
942
+ // and a target file under one is the artefact of a legitimate run. `oa-validate`
943
+ // records its verdict in the tree it measured (d.232), so a run over the
944
+ // mirror leaves `ci/deployability.json` there — git does not see it, and
945
+ // neither does this check (d.237).
946
+ const ignored = templateIgnores();
947
+
948
+ for (const [relative, text] of tree) {
949
+ if (ignored(relative)) continue;
950
+ const file = path.join(target, ...relative.split('/'));
951
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
952
+
953
+ if (current === text) continue;
954
+ if (options.check) {
955
+ drifted.push(`[oa-sync-template] template would change ${relative} - `
956
+ + `${current === null ? 'absent' : 'differs from the packaged template'}\n`);
957
+ continue;
958
+ }
959
+ writeServiceFile(target, relative, text);
960
+ written += 1;
961
+ }
962
+
963
+ const surplus = fs.existsSync(target)
964
+ ? listOutputFiles(target).filter((relative) => !tree.has(relative))
965
+ : [];
966
+ for (const relative of surplus) {
967
+ drifted.push(`[oa-sync-template] template carries ${relative}, which the packaged template does not - `
968
+ + 'the directory is output, so a file with no source in it is stale\n');
969
+ }
970
+
971
+ if (drifted.length > 0) {
972
+ process.stderr.write(`${drifted.join('')}Fix: npx oa-sync-template template --target ${target}\n`);
973
+ return 1;
974
+ }
975
+ process.stdout.write(`[oa-sync-template] template ${options.check ? 'is in sync' : `wrote ${written} of ${tree.size} files`} - ${target}\n`);
976
+ return 0;
977
+ }
978
+
979
+ const SUBCOMMANDS = Object.freeze({
980
+ 'shared-env': runSharedEnv,
981
+ 'readme-uniform': runReadmeUniform,
982
+ 'docs-region': runDocsRegion,
983
+ template: runTemplate
984
+ });
985
+
986
+ /**
987
+ * @param {string[]} argv arguments after the executable and script
988
+ * @returns {number} process exit code
989
+ */
990
+ function main(argv) {
991
+ let options;
992
+ try {
993
+ options = parseArgs(argv);
994
+ } catch (error) {
995
+ process.stderr.write(`${error.message}\n`);
996
+ return USAGE_EXIT;
997
+ }
998
+
999
+ const run = options.newName !== null
1000
+ ? runNew
1001
+ : (options.command !== null ? SUBCOMMANDS[options.command] : null);
1002
+
1003
+ if (run === null && options.paths.length === 0 && options.target === null) {
1004
+ process.stderr.write(`${USAGE}`);
1005
+ return USAGE_EXIT;
1006
+ }
1007
+
1008
+ try {
1009
+ return (run === null ? runSync : run)(options);
1010
+ } catch (error) {
1011
+ process.stderr.write(`${error.message}\n`);
1012
+ return USAGE_EXIT;
1013
+ }
1014
+ }
1015
+
1016
+ if (require.main === module) {
1017
+ process.exitCode = main(process.argv.slice(2));
1018
+ }
1019
+
1020
+ module.exports = { main, parseArgs, USAGE_EXIT, MANIFEST_RELATIVE, SHARED_ENV_RELATIVE, README_FILE };