@onlineapps/conn-orch-validator 7.0.0 → 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.
- package/CHANGELOG.md +2558 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- 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 };
|