rman 1.0.12 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -70
- package/cli.js +226 -14
- package/commands/build.command.js +1 -0
- package/commands/changed.command.js +2 -2
- package/commands/changelog.command.js +13 -15
- package/commands/config.command.js +61 -0
- package/commands/diff.command.js +9 -4
- package/commands/exec.command.js +2 -8
- package/commands/github-release.command.js +1 -0
- package/commands/info.command.d.ts +9 -0
- package/commands/info.command.js +12 -2
- package/commands/run.command.js +5 -8
- package/commands/test.command.js +1 -0
- package/commands/version.command.js +53 -14
- package/constants.js +1 -1
- package/core/config.d.ts +265 -17
- package/core/config.js +651 -76
- package/core/custom-command.d.ts +133 -0
- package/core/custom-command.js +99 -0
- package/core/extends-config.d.ts +27 -0
- package/core/extends-config.js +89 -0
- package/core/manifest.d.ts +222 -0
- package/core/manifest.js +150 -0
- package/core/merge-config.d.ts +70 -0
- package/core/merge-config.js +193 -0
- package/core/package.d.ts +73 -7
- package/core/package.js +86 -24
- package/core/plugin.d.ts +112 -0
- package/core/plugin.js +189 -0
- package/core/repository.d.ts +91 -1
- package/core/repository.js +277 -132
- package/core/resolve-target.d.ts +12 -0
- package/core/resolve-target.js +33 -0
- package/core/run-step.d.ts +75 -0
- package/core/run-step.js +1 -0
- package/core/version-scheme.d.ts +134 -0
- package/core/version-scheme.js +148 -0
- package/core/workspace.d.ts +68 -0
- package/core/workspace.js +83 -0
- package/index.d.ts +55 -8
- package/index.js +42 -7
- package/interfaces/rman-config.interface.d.ts +222 -46
- package/package.json +16 -7
- package/services/change-hash.service.d.ts +88 -0
- package/services/change-hash.service.js +112 -0
- package/services/changelog.service.d.ts +8 -13
- package/services/changelog.service.js +12 -11
- package/services/conventional-commits.service.d.ts +73 -0
- package/services/conventional-commits.service.js +116 -0
- package/services/docker-publish.service.js +1 -1
- package/services/exec.service.js +1 -1
- package/services/github-release.service.d.ts +2 -2
- package/services/github-release.service.js +10 -5
- package/services/list.service.js +5 -2
- package/services/run.service.d.ts +112 -6
- package/services/run.service.js +265 -89
- package/services/system-info.d.ts +22 -7
- package/services/system-info.js +8 -23
- package/services/version-plan.service.d.ts +244 -0
- package/services/version-plan.service.js +414 -0
- package/services/version.service.d.ts +102 -82
- package/services/version.service.js +226 -434
- package/services.d.ts +5 -3
- package/services.js +5 -3
- package/utils/bin-path.d.ts +59 -0
- package/utils/bin-path.js +82 -0
- package/utils/child-tracker.d.ts +16 -0
- package/utils/child-tracker.js +30 -0
- package/utils/exec.d.ts +13 -2
- package/utils/exec.js +17 -17
- package/utils/git.d.ts +9 -3
- package/utils/git.js +10 -2
- package/utils/package-filter.d.ts +33 -2
- package/utils/package-filter.js +47 -7
- package/utils/printable-config.d.ts +15 -0
- package/utils/printable-config.js +42 -0
- package/utils/release-version.js +3 -3
- package/utils/run-bin.d.ts +46 -0
- package/utils/run-bin.js +63 -0
- package/utils/version-stamp.d.ts +14 -6
- package/utils/version-stamp.js +25 -13
- package/commands/ci.command.js +0 -30
- package/commands/clean.command.d.ts +0 -3
- package/commands/clean.command.js +0 -36
- package/commands/publish.command.d.ts +0 -3
- package/commands/publish.command.js +0 -225
- package/rmanrc.schema.json +0 -392
- package/services/ci.service.d.ts +0 -40
- package/services/ci.service.js +0 -204
- package/services/clean.service.d.ts +0 -42
- package/services/clean.service.js +0 -226
- package/services/publish.service.d.ts +0 -79
- package/services/publish.service.js +0 -273
- package/utils/change-hash.d.ts +0 -68
- package/utils/change-hash.js +0 -98
- package/utils/conventional-commits.d.ts +0 -52
- package/utils/conventional-commits.js +0 -90
- package/utils/npm-run-path.d.ts +0 -67
- package/utils/npm-run-path.js +0 -63
- package/utils/workspace-range.d.ts +0 -17
- package/utils/workspace-range.js +0 -28
- /package/commands/{ci.command.d.ts → config.command.d.ts} +0 -0
package/core/config.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { DOMParser } from '@xmldom/xmldom';
|
|
1
2
|
import fs from 'fs';
|
|
3
|
+
import ini from 'ini';
|
|
2
4
|
import * as yaml from 'js-yaml';
|
|
3
5
|
import { createRequire } from 'module';
|
|
4
6
|
import path from 'path';
|
|
5
|
-
import merge from 'putil-merge';
|
|
6
7
|
import semver from 'semver';
|
|
7
8
|
import { pathToFileURL } from 'url';
|
|
8
9
|
import vm from 'vm';
|
|
10
|
+
import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
|
|
11
|
+
import { finalizeConfig, mergeConfig, PREVIOUS_VALUE } from './merge-config.js';
|
|
9
12
|
/**
|
|
10
13
|
* Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
|
|
11
14
|
* autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
|
|
@@ -58,33 +61,56 @@ async function loadJsConfig(file) {
|
|
|
58
61
|
*/
|
|
59
62
|
export async function readDirConfig(dirname) {
|
|
60
63
|
const result = {};
|
|
64
|
+
/** The file an `extends` in this directory resolves relative to. The last form that actually
|
|
65
|
+
* declared one wins, which matters only for the unusual directory holding several. */
|
|
66
|
+
let extendsFrom = path.join(dirname, '.rmanrc');
|
|
61
67
|
const pkgJsonFile = path.join(dirname, 'package.json');
|
|
62
68
|
if (fs.existsSync(pkgJsonFile)) {
|
|
63
69
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
|
|
64
|
-
if (pkgJson && typeof pkgJson.rman === 'object')
|
|
65
|
-
|
|
70
|
+
if (pkgJson && typeof pkgJson.rman === 'object') {
|
|
71
|
+
assertNoSelectorExtends(pkgJson.rman, pkgJsonFile);
|
|
72
|
+
if (EXTENDS_KEY in pkgJson.rman)
|
|
73
|
+
extendsFrom = pkgJsonFile;
|
|
74
|
+
mergeConfig(result, pkgJson.rman);
|
|
75
|
+
}
|
|
66
76
|
}
|
|
67
77
|
const ymlFile = path.join(dirname, '.rmanrc.yml');
|
|
68
78
|
if (fs.existsSync(ymlFile)) {
|
|
69
79
|
const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
|
|
70
|
-
if (obj && typeof obj === 'object')
|
|
71
|
-
|
|
80
|
+
if (obj && typeof obj === 'object') {
|
|
81
|
+
assertNoSelectorExtends(obj, ymlFile);
|
|
82
|
+
if (EXTENDS_KEY in obj)
|
|
83
|
+
extendsFrom = ymlFile;
|
|
84
|
+
mergeConfig(result, obj);
|
|
85
|
+
}
|
|
72
86
|
}
|
|
73
87
|
const rcFile = path.join(dirname, '.rmanrc');
|
|
74
88
|
if (fs.existsSync(rcFile)) {
|
|
75
89
|
const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
|
|
76
|
-
if (obj && typeof obj === 'object')
|
|
77
|
-
|
|
90
|
+
if (obj && typeof obj === 'object') {
|
|
91
|
+
assertNoSelectorExtends(obj, rcFile);
|
|
92
|
+
if (EXTENDS_KEY in obj)
|
|
93
|
+
extendsFrom = rcFile;
|
|
94
|
+
mergeConfig(result, obj);
|
|
95
|
+
}
|
|
78
96
|
}
|
|
79
97
|
for (const jsFileName of JS_CONFIG_FILES) {
|
|
80
98
|
const jsFile = path.join(dirname, jsFileName);
|
|
81
99
|
if (fs.existsSync(jsFile)) {
|
|
82
100
|
const obj = await loadJsConfig(jsFile);
|
|
83
|
-
if (obj && typeof obj === 'object')
|
|
84
|
-
|
|
101
|
+
if (obj && typeof obj === 'object') {
|
|
102
|
+
assertNoSelectorExtends(obj, jsFile);
|
|
103
|
+
if (EXTENDS_KEY in obj)
|
|
104
|
+
extendsFrom = jsFile;
|
|
105
|
+
mergeConfig(result, obj);
|
|
106
|
+
}
|
|
85
107
|
}
|
|
86
108
|
}
|
|
87
|
-
|
|
109
|
+
/** Resolved per directory, once its own forms have been combined: `extends` is the base every
|
|
110
|
+
* one of them sits on, and the directory chain then layers on top as it always did. Each form
|
|
111
|
+
* was checked for a misplaced `extends` as it was read, so that error can name the file holding
|
|
112
|
+
* it rather than whichever form happened to declare the real one. */
|
|
113
|
+
return resolveExtends(result, extendsFrom);
|
|
88
114
|
}
|
|
89
115
|
/**
|
|
90
116
|
* Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
|
|
@@ -97,9 +123,10 @@ export async function readDirConfig(dirname) {
|
|
|
97
123
|
* `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
|
|
98
124
|
* (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
|
|
99
125
|
* not, silently, every package under it.
|
|
100
|
-
* - **A `"[selector]"` block configures the packages it names**
|
|
101
|
-
* `"[
|
|
102
|
-
*
|
|
126
|
+
* - **A `"[selector]"` block configures the packages it names** - `"[*]"` for all of them (the root
|
|
127
|
+
* included), `"[ws:*]"` for every one but the root, `"[/]"` for the root alone, `"[*-dialect]"`
|
|
128
|
+
* for a glob over package names. See `parseSelector`. This is the only way a directory speaks
|
|
129
|
+
* about anything but its own package.
|
|
103
130
|
*
|
|
104
131
|
* Splitting the two matters because the same key means different things to the two audiences. The
|
|
105
132
|
* clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
|
|
@@ -107,64 +134,137 @@ export async function readDirConfig(dirname) {
|
|
|
107
134
|
* cascade that fed one declaration to both ran a package-relative command (`node
|
|
108
135
|
* ../../support/postbuild.cjs`) at the root, where it cannot resolve.
|
|
109
136
|
*
|
|
110
|
-
* `packageName` is what selectors match against; without it
|
|
111
|
-
*
|
|
137
|
+
* `packageName` is what selectors match against; without it, selector blocks contribute nothing at
|
|
138
|
+
* all. The root package passes its own, since `"[/]"` and `"[*]"` speak to it.
|
|
112
139
|
*/
|
|
113
140
|
export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
|
|
114
141
|
const result = {};
|
|
115
142
|
const target = path.resolve(targetDir);
|
|
143
|
+
/** The root *package* is the one whose directory is the repository root - no other test is
|
|
144
|
+
* needed, and none would be as reliable: a name can be anything. In a single-package repository
|
|
145
|
+
* that is the only package, so `"[/]"` reaches it and `"[ws:*]"` reaches nothing. */
|
|
146
|
+
const isRoot = target === path.resolve(rootDir);
|
|
116
147
|
for (const dir of dirChain(rootDir, targetDir)) {
|
|
117
148
|
let local = cache.get(dir);
|
|
118
149
|
if (!local) {
|
|
119
150
|
local = await readDirConfig(dir);
|
|
120
151
|
cache.set(dir, local);
|
|
121
152
|
}
|
|
122
|
-
// Selectors first, so a directory's own unmarked config still wins over a selector declared
|
|
123
|
-
// alongside it - "this package" is a more specific statement than "packages matching a glob".
|
|
124
|
-
if (packageName) {
|
|
125
|
-
for (const block of matchingSelectors(local, packageName))
|
|
126
|
-
merge(result, block, { deep: true });
|
|
127
|
-
}
|
|
128
153
|
// A directory holding a package speaks for that package only - which is what keeps the root's
|
|
129
154
|
// own config off every package under it. A directory that holds none (an intermediate
|
|
130
155
|
// `packages/`, say) has no package to speak for, so its unmarked config can only mean
|
|
131
156
|
// "everything below" and still cascades.
|
|
132
157
|
const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
|
|
133
|
-
|
|
134
|
-
|
|
158
|
+
const speaksForTarget = !ownsAPackage || path.resolve(dir) === target;
|
|
159
|
+
/**
|
|
160
|
+
* `vars` is the **one** unmarked key that cascades past the package its directory speaks for,
|
|
161
|
+
* and it is not a hole in that rule - it is a key the rule was never about. The rule exists
|
|
162
|
+
* because a setting means different things to the two audiences (`run.build.after` on the root
|
|
163
|
+
* is a repo-wide bookend, on a package its own hook), so one declaration cannot serve both.
|
|
164
|
+
* `vars: {x: 1}` means the number 1 to everyone; there is no second audience to be wrong for.
|
|
165
|
+
*
|
|
166
|
+
* Merged *before* this directory's selector blocks, so `"[*]": {vars: ...}` - which names the
|
|
167
|
+
* packages explicitly - overrides the same directory's plainer statement.
|
|
168
|
+
*/
|
|
169
|
+
if (!speaksForTarget && local.vars !== undefined)
|
|
170
|
+
mergeConfig(result, { vars: local.vars });
|
|
171
|
+
// Selectors next, so a directory's own unmarked config still wins over a selector declared
|
|
172
|
+
// alongside it - "this package" is a more specific statement than "packages matching a glob".
|
|
173
|
+
if (packageName) {
|
|
174
|
+
for (const block of matchingSelectors(local, packageName, isRoot))
|
|
175
|
+
mergeConfig(result, block);
|
|
176
|
+
}
|
|
177
|
+
if (speaksForTarget)
|
|
178
|
+
mergeConfig(result, stripSelectors(local));
|
|
135
179
|
}
|
|
136
|
-
|
|
180
|
+
/** Every layer has had its turn, so an append still outstanding has nothing left to attach to
|
|
181
|
+
* and becomes the value itself. Done here rather than per layer: until the chain is finished,
|
|
182
|
+
* the key it appends to may still be coming. */
|
|
183
|
+
return finalizeConfig(result);
|
|
137
184
|
}
|
|
138
|
-
/** A config key naming packages rather than settings: `"[*]"`, `"[
|
|
185
|
+
/** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[ws:*]"`, `"[pkg-a]"`. The
|
|
139
186
|
* brackets are what keep this space from colliding with real config keys - no setting starts with
|
|
140
187
|
* one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
|
|
141
188
|
* parses as a flow sequence. */
|
|
142
189
|
export function isSelectorKey(key) {
|
|
143
190
|
return key.length > 2 && key.startsWith('[') && key.endsWith(']');
|
|
144
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* **Which packages a selector speaks for.** Three audiences, because a repository has three:
|
|
194
|
+
*
|
|
195
|
+
* | | |
|
|
196
|
+
* | --- | --- |
|
|
197
|
+
* | `"[/]"` | the **root package** only |
|
|
198
|
+
* | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | **every** package the glob matches, root included |
|
|
199
|
+
* | `"[ws:*]"`, `"[workspace:pkg-*]"` | every **non-root** package the glob matches |
|
|
200
|
+
*
|
|
201
|
+
* `/` for the root because that is what a repository root is called everywhere else, and it cannot
|
|
202
|
+
* collide with a package name. `ws:` is a qualifier on the glob rather than a separate spelling of
|
|
203
|
+
* `*`, so `"[ws:pkg-*]"` means what it looks like.
|
|
204
|
+
*
|
|
205
|
+
* **`"[*]"` includes the root, and that is a change from how it used to read.** Before, selectors
|
|
206
|
+
* were not applied to the root at all, so `"[*]"` silently meant "the workspace packages" - a
|
|
207
|
+
* catch-all with an exception nothing in the syntax mentioned. The three names above say which
|
|
208
|
+
* audience is meant; `"[ws:*]"` is the old behaviour, now spelled.
|
|
209
|
+
*/
|
|
210
|
+
export function parseSelector(key) {
|
|
211
|
+
const inner = key.slice(1, -1);
|
|
212
|
+
if (inner === ROOT_SELECTOR_INNER)
|
|
213
|
+
return { scope: 'root', test: () => true };
|
|
214
|
+
for (const prefix of WORKSPACE_PREFIXES) {
|
|
215
|
+
if (inner.startsWith(prefix)) {
|
|
216
|
+
const re = globToRegExp(inner.slice(prefix.length));
|
|
217
|
+
return { scope: 'workspace', test: name => re.test(name) };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const re = globToRegExp(inner);
|
|
221
|
+
return { scope: 'all', test: name => re.test(name) };
|
|
222
|
+
}
|
|
145
223
|
/** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
|
|
146
224
|
* `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
|
|
147
225
|
* pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
|
|
148
226
|
export function selectorToRegExp(key) {
|
|
149
|
-
|
|
150
|
-
const source = glob
|
|
151
|
-
.split('*')
|
|
152
|
-
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
153
|
-
.join('.*');
|
|
154
|
-
return new RegExp(`^${source}$`);
|
|
227
|
+
return globToRegExp(key.slice(1, -1));
|
|
155
228
|
}
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Every selector block in `config` that speaks for this package, in increasing precedence.
|
|
231
|
+
*
|
|
232
|
+
* Order, lowest first: **`"[*]"`, then a catch-all `"[ws:*]"`, then the rest in declaration
|
|
233
|
+
* order** - so narrowing the audience wins over the widest one, a named package or `"[/]"` wins
|
|
234
|
+
* over both, and two equally specific globs resolve by the order they were written in. A catch-all
|
|
235
|
+
* is ranked rather than left to declaration order on purpose: where you happen to write "everything"
|
|
236
|
+
* should not decide whether it beats a rule about one package.
|
|
237
|
+
*/
|
|
238
|
+
function matchingSelectors(config, packageName, isRoot) {
|
|
160
239
|
const matches = [];
|
|
161
240
|
for (const [key, value] of Object.entries(config)) {
|
|
162
241
|
if (!isSelectorKey(key) || !value || typeof value !== 'object')
|
|
163
242
|
continue;
|
|
164
|
-
|
|
165
|
-
|
|
243
|
+
const { scope, test } = parseSelector(key);
|
|
244
|
+
if (scope === 'root' && !isRoot)
|
|
245
|
+
continue;
|
|
246
|
+
if (scope === 'workspace' && isRoot)
|
|
247
|
+
continue;
|
|
248
|
+
if (!test(packageName))
|
|
249
|
+
continue;
|
|
250
|
+
matches.push([selectorRank(key), value]);
|
|
166
251
|
}
|
|
167
|
-
return matches.sort((a, b) =>
|
|
252
|
+
return matches.sort((a, b) => a[0] - b[0]).map(([, block]) => block);
|
|
253
|
+
}
|
|
254
|
+
/** 0 for `"[*]"`, 1 for a catch-all workspace selector, 2 for anything that names something. Equal
|
|
255
|
+
* ranks keep their declaration order, since `Array.prototype.sort` is stable. */
|
|
256
|
+
function selectorRank(key) {
|
|
257
|
+
if (key === CATCH_ALL)
|
|
258
|
+
return 0;
|
|
259
|
+
const inner = key.slice(1, -1);
|
|
260
|
+
return WORKSPACE_PREFIXES.some(prefix => inner === `${prefix}*`) ? 1 : 2;
|
|
261
|
+
}
|
|
262
|
+
function globToRegExp(glob) {
|
|
263
|
+
const source = glob
|
|
264
|
+
.split('*')
|
|
265
|
+
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
266
|
+
.join('.*');
|
|
267
|
+
return new RegExp(`^${source}$`);
|
|
168
268
|
}
|
|
169
269
|
function stripSelectors(config) {
|
|
170
270
|
const result = {};
|
|
@@ -174,6 +274,12 @@ function stripSelectors(config) {
|
|
|
174
274
|
return result;
|
|
175
275
|
}
|
|
176
276
|
const CATCH_ALL = '[*]';
|
|
277
|
+
/** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
|
|
278
|
+
* unable to collide with a package name. */
|
|
279
|
+
const ROOT_SELECTOR_INNER = '/';
|
|
280
|
+
/** Both spellings of "the workspace packages, not the root". The long one reads in a config file
|
|
281
|
+
* someone else has to understand; the short one is what gets typed. */
|
|
282
|
+
const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
|
|
177
283
|
function dirChain(rootDir, targetDir) {
|
|
178
284
|
const rel = path.relative(rootDir, targetDir);
|
|
179
285
|
if (!rel || rel === '.' || rel.startsWith('..'))
|
|
@@ -186,61 +292,404 @@ function dirChain(rootDir, targetDir) {
|
|
|
186
292
|
}
|
|
187
293
|
return dirs;
|
|
188
294
|
}
|
|
295
|
+
export function interpolateConfig(config, scope, options) {
|
|
296
|
+
const skip = options?.skip ?? [];
|
|
297
|
+
/**
|
|
298
|
+
* Where `config` sits in the whole config, when a caller hands over a fragment rather than the
|
|
299
|
+
* root - `version` interpolates its own `version.<slot>` value on its own, those three paths being
|
|
300
|
+
* in `DEFERRED_PATHS`.
|
|
301
|
+
*
|
|
302
|
+
* It matters because the path is what decides whether a function is a value to compute or a step
|
|
303
|
+
* to leave alone (`STEP_PATHS`). Without it, a fragment starts at the root and matches nothing, so
|
|
304
|
+
* a function in a `version` hook was called while the hook was being *prepared* - measured, and it
|
|
305
|
+
* failed inside the user's own code with `path.join` receiving undefined.
|
|
306
|
+
*/
|
|
307
|
+
const base = options?.at ?? [];
|
|
308
|
+
/**
|
|
309
|
+
* Built from `scope`'s property **descriptors**, never `{ ...scope }`.
|
|
310
|
+
*
|
|
311
|
+
* A spread reads every property, so a lazy getter on the scope is no longer lazy the moment one
|
|
312
|
+
* is added - and `git` is exactly that: it shells out to `git rev-parse`, and a spread here would
|
|
313
|
+
* do it on `rman list`, `rman info` and every other command, in a repository whose config never
|
|
314
|
+
* mentions git. (The same trap `pkg.targetVersion` documents from the other side: it is a
|
|
315
|
+
* *throwing* getter, and being enumerable is what made a spread fire it.)
|
|
316
|
+
*/
|
|
317
|
+
const context = vm.createContext(Object.defineProperties({}, Object.getOwnPropertyDescriptors(scope)));
|
|
318
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
319
|
+
return walk(config, scope, context, base, skip);
|
|
320
|
+
/**
|
|
321
|
+
* The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
|
|
322
|
+
* restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
|
|
323
|
+
* copy that drifts when the first one changes.
|
|
324
|
+
*
|
|
325
|
+
* Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
|
|
326
|
+
* and handing an expression whatever was ready would make the answer depend on key order in the
|
|
327
|
+
* file, which is exactly the kind of quiet wrongness this evaluator exists to prevent: a key
|
|
328
|
+
* declared above would read as resolved and one below as raw. On demand, each key is resolved
|
|
329
|
+
* when first read and the order in the file means nothing.
|
|
330
|
+
*/
|
|
331
|
+
const resolved = new Map();
|
|
332
|
+
const resolving = [];
|
|
333
|
+
/**
|
|
334
|
+
* A cycle is **recorded here rather than thrown from the getter**, and that is not a style
|
|
335
|
+
* choice: a host getter that throws inside a `vm` property interceptor has its exception
|
|
336
|
+
* swallowed, and V8 then reports the global as absent - so a self-referencing key came out as
|
|
337
|
+
* `publish is not defined`, which sends the reader looking for a missing key instead of a loop
|
|
338
|
+
* (measured). The getter returns `undefined`, the resulting `ReferenceError` is caught below, and
|
|
339
|
+
* this replaces it.
|
|
340
|
+
*/
|
|
341
|
+
let cycle;
|
|
342
|
+
const resolve = (key) => {
|
|
343
|
+
if (resolved.has(key))
|
|
344
|
+
return resolved.get(key);
|
|
345
|
+
if (resolving.includes(key)) {
|
|
346
|
+
cycle ??= new Error(`Config expression forms a cycle: ${[...resolving, key].join(' -> ')}\n` +
|
|
347
|
+
` A value cannot be derived from itself, directly or through another key.`);
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
resolving.push(key);
|
|
351
|
+
try {
|
|
352
|
+
const value = walk(config[key], scope, context, [...base, key], skip);
|
|
353
|
+
resolved.set(key, value);
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
/** Not cleared: a cycle aborts the whole interpolation, and each level up would otherwise
|
|
358
|
+
* re-swallow its own replacement the same way - leaving it set lets the outermost frame,
|
|
359
|
+
* the one with a real stack to throw from, report it. */
|
|
360
|
+
if (cycle)
|
|
361
|
+
throw new Error(`${String(e?.message).split('\n')[0]}\n ${cycle.message}`, { cause: e });
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
resolving.pop();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
for (const key of Object.keys(config)) {
|
|
369
|
+
/** A scope binding wins: `pkg`/`repository`/`env`/`semver` are not config keys, so nothing
|
|
370
|
+
* collides today, and a future key that did must not silently take over the namespace. */
|
|
371
|
+
if (key in scope || !IDENTIFIER.test(key))
|
|
372
|
+
continue;
|
|
373
|
+
Object.defineProperty(context, key, { enumerable: true, configurable: true, get: () => resolve(key) });
|
|
374
|
+
}
|
|
375
|
+
/** Built through the same memo the getters use, so every key is walked exactly once whether an
|
|
376
|
+
* expression asked for it first or the result did. */
|
|
377
|
+
const result = {};
|
|
378
|
+
for (const key of Object.keys(config))
|
|
379
|
+
result[key] = resolve(key);
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
189
382
|
/**
|
|
190
|
-
*
|
|
191
|
-
* the
|
|
383
|
+
* Config paths left untouched when a repository's config is first resolved, and evaluated only by
|
|
384
|
+
* the command that runs them.
|
|
192
385
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
*
|
|
386
|
+
* `version`'s own hooks are the one place `${{ pkg.targetVersion }}` makes sense, and the version
|
|
387
|
+
* being written is not known until `version` has computed its plan - long after the config was
|
|
388
|
+
* resolved. Evaluating these eagerly would throw while merely *loading* the repository, so any
|
|
389
|
+
* command at all would fail on a config that mentions it.
|
|
390
|
+
*/
|
|
391
|
+
export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
|
|
392
|
+
/**
|
|
393
|
+
* Paths whose value is a **step** - something to run later - rather than a setting to compute now.
|
|
394
|
+
* `*` matches one path segment (`run.<script>.exec`).
|
|
201
395
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
396
|
+
* This is what tells a step function from a value function, and the two live side by side in one
|
|
397
|
+
* config:
|
|
204
398
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
399
|
+
* ```js
|
|
400
|
+
* '[ws:*]': {
|
|
401
|
+
* clean: { include: ({ vars, value }) => [...value, vars.buildDir] }, // a value: called here
|
|
402
|
+
* run: { build: { after: ({ pkg }) => copyDocs(pkg) } }, // a step: called by `run`
|
|
403
|
+
* }
|
|
404
|
+
* ```
|
|
208
405
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
406
|
+
* **The key decides, and it already did.** `run.build.exec: 'tsc -b'` is a shell command and
|
|
407
|
+
* `publish.directory: 'build'` is a path - not because of anything about the strings, but because of
|
|
408
|
+
* where they sit. A function inherits the same rule, so nothing new has to be learned and no marker
|
|
409
|
+
* has to be remembered. The alternative was inspecting the function (arity, parameter names), which
|
|
410
|
+
* is the kind of guess `loadPlugins` refuses to make about a module's export for the same reason:
|
|
411
|
+
* guessing wrong here means running build-time code while merely loading the repository, or
|
|
412
|
+
* silently never running it.
|
|
213
413
|
*
|
|
214
|
-
* A string
|
|
215
|
-
*
|
|
216
|
-
|
|
414
|
+
* A **string** at one of these paths is still interpolated - `exec: 'tsc -b ${{ file.resolve(...) }}'`
|
|
415
|
+
* has to keep working - so this is narrower than `DEFERRED_PATHS`, which skips its paths entirely.
|
|
416
|
+
*/
|
|
417
|
+
export const STEP_PATHS = [
|
|
418
|
+
/** The bare-value shorthand: `run: { build: fn }` means `{ exec: fn }`, as `run: { build: 'cmd' }`
|
|
419
|
+
* means `{ exec: 'cmd' }`. Missing it made the two spellings disagree about *when* the function
|
|
420
|
+
* runs, which is worse than not supporting the short one at all. */
|
|
421
|
+
'run.*',
|
|
422
|
+
'run.*.before',
|
|
423
|
+
'run.*.exec',
|
|
424
|
+
'run.*.after',
|
|
425
|
+
/** A condition, evaluated per package by `RunService` when the run reaches it. Called here
|
|
426
|
+
* instead, it collapsed to the boolean it happened to return at load time - and `parseIfExpr`
|
|
427
|
+
* then read that boolean as "no condition given", so the script ran unconditionally (measured). */
|
|
428
|
+
'run.*.if',
|
|
429
|
+
'version.before',
|
|
430
|
+
'version.exec',
|
|
431
|
+
'version.after',
|
|
432
|
+
];
|
|
433
|
+
/**
|
|
434
|
+
* Keys whose **whole subtree** is code rather than config, so no function under them is a value to
|
|
435
|
+
* compute. `plugins` is the only one, and it has to be here: an entry may be the plugin *object*
|
|
436
|
+
* itself, and an `RmanPlugin` is almost entirely functions - `manifest.read`, `workspace.resolve`,
|
|
437
|
+
* `versionPlanner`, `binPaths`, and every command's `builder` and `handler`.
|
|
217
438
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
439
|
+
* Measured, and it is why this exists: with `plugins` walked like any other key, resolving the
|
|
440
|
+
* config of a repository that named a plugin called that plugin's yargs builder with the config
|
|
441
|
+
* scope - `Config function in "plugins[0].commands[0].builder" failed: cmd.option is not a
|
|
442
|
+
* function`. A `plugins` entry is loaded by `loadPlugins`, never read as a setting.
|
|
443
|
+
*/
|
|
444
|
+
export const CODE_SUBTREES = ['plugins'];
|
|
445
|
+
const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
|
|
446
|
+
/** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
|
|
447
|
+
* `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
|
|
448
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
449
|
+
/** The `file` namespace for one package's directory - see `FileScope`. */
|
|
450
|
+
/**
|
|
451
|
+
* `read` in a `${{ ... }}` expression (and in a value function): a structured file's **contents**,
|
|
452
|
+
* parsed - where `file` answers only where a path is.
|
|
222
453
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
454
|
+
* ```yaml
|
|
455
|
+
* "[*]":
|
|
456
|
+
* run:
|
|
457
|
+
* build:
|
|
458
|
+
* exec: 'tsc --outDir ${{ read("tsconfig.json").compilerOptions.outDir }}'
|
|
459
|
+
* ```
|
|
460
|
+
*
|
|
461
|
+
* `cache` is shared across every package (see `Repository.configScope`) and keyed by what the file
|
|
462
|
+
* *is*, not merely where - so the same file read by twenty packages is parsed once, and a file rman
|
|
463
|
+
* itself rewrites mid-run is re-read rather than remembered. See `readStructuredFile`.
|
|
225
464
|
*/
|
|
226
|
-
export function
|
|
227
|
-
|
|
228
|
-
|
|
465
|
+
export function createReadScope(dirname, cache) {
|
|
466
|
+
return (target, format) => {
|
|
467
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
468
|
+
throw new Error('read() needs a path - it was given ' + JSON.stringify(target));
|
|
469
|
+
}
|
|
470
|
+
return readStructuredFile(path.resolve(dirname, target), format, cache);
|
|
471
|
+
};
|
|
229
472
|
}
|
|
230
|
-
|
|
231
|
-
|
|
473
|
+
export function createFileScope(dirname) {
|
|
474
|
+
const locate = (target) => {
|
|
475
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
476
|
+
throw new Error('file.exists()/file.resolve() need a path - they were given ' + JSON.stringify(target));
|
|
477
|
+
}
|
|
478
|
+
const resolved = path.resolve(dirname, target);
|
|
479
|
+
return { path: resolved, found: fs.existsSync(resolved) };
|
|
480
|
+
};
|
|
481
|
+
return {
|
|
482
|
+
exists(target) {
|
|
483
|
+
const { path: resolved, found } = locate(target);
|
|
484
|
+
return found ? resolved : '';
|
|
485
|
+
},
|
|
486
|
+
resolve(target) {
|
|
487
|
+
const { path: resolved, found } = locate(target);
|
|
488
|
+
if (!found) {
|
|
489
|
+
throw new Error(`file.resolve("${target}") found nothing at ${resolved}\n` +
|
|
490
|
+
` Use file.exists() instead if its absence is a case to handle rather than a mistake.`);
|
|
491
|
+
}
|
|
492
|
+
return resolved;
|
|
493
|
+
},
|
|
494
|
+
resolveFirst(...targets) {
|
|
495
|
+
if (!targets.length)
|
|
496
|
+
throw new Error('file.resolveFirst() needs at least one path');
|
|
497
|
+
for (const target of targets) {
|
|
498
|
+
const { path: resolved, found } = locate(target);
|
|
499
|
+
if (found)
|
|
500
|
+
return resolved;
|
|
501
|
+
}
|
|
502
|
+
throw new Error(`file.resolveFirst() found none of: ${targets.map(t => `"${t}"`).join(', ')}\n` + ` Looked in ${dirname}.`);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function walk(value, scope, context, at, skip) {
|
|
507
|
+
/** Compared on the key path rather than the value, so a deferred key's whole subtree - a single
|
|
508
|
+
* command or an array of them - is handed on untouched. */
|
|
509
|
+
if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
|
|
510
|
+
return value;
|
|
511
|
+
if (typeof value === 'function') {
|
|
512
|
+
/** Code, not a value: a step for `run`/`version` to call in its own time, or a plugin's own
|
|
513
|
+
* function. Carried through exactly as a command string would be - calling it here would run
|
|
514
|
+
* build-time work while merely *loading* the repository, which is the whole distinction the
|
|
515
|
+
* function form exists to draw. */
|
|
516
|
+
if (isCodePath(at))
|
|
517
|
+
return value;
|
|
518
|
+
return callValueFn(value, scope, context, at, skip);
|
|
519
|
+
}
|
|
232
520
|
if (typeof value === 'string')
|
|
233
521
|
return interpolateString(value, context, at);
|
|
234
522
|
if (Array.isArray(value))
|
|
235
|
-
return value.map((item, i) => walk(item, scope, context, [...at, i]));
|
|
523
|
+
return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
|
|
236
524
|
if (value && typeof value === 'object') {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
525
|
+
return withScopedVars(value, scope, context, at, skip, () => {
|
|
526
|
+
const result = {};
|
|
527
|
+
for (const [key, item] of Object.entries(value))
|
|
528
|
+
result[key] = walk(item, scope, context, [...at, key], skip);
|
|
529
|
+
return result;
|
|
530
|
+
});
|
|
241
531
|
}
|
|
242
532
|
return value;
|
|
243
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Runs `body` with `vars` scoped to this node: **a fresh copy at every level**, with the node's own
|
|
536
|
+
* `vars` block - if it declares one - merged over what the level above resolved to.
|
|
537
|
+
*
|
|
538
|
+
* ```yaml
|
|
539
|
+
* vars: { x: 1 }
|
|
540
|
+
* run:
|
|
541
|
+
* vars: { x: 2 }
|
|
542
|
+
* clean: { before: '${{ read(vars.x + ".json") }}' } # 2.json
|
|
543
|
+
* build:
|
|
544
|
+
* vars: { x: 3 }
|
|
545
|
+
* before: '${{ read(vars.x + ".json") }}' # 3.json
|
|
546
|
+
* ```
|
|
547
|
+
*
|
|
548
|
+
* **Copied at every node, not only where a `vars` block appears**, and that is the difference
|
|
549
|
+
* between scoping and leaking: a value function is handed this object, so one that writes to it
|
|
550
|
+
* (`vars.built = Date.now()`) must not be writing into the level above. Without a copy per node,
|
|
551
|
+
* a write inside `run.build` would land in `run`'s object and `run.clean` would see it. Merged per
|
|
552
|
+
* key rather than replaced, so redeclaring one var keeps the rest - the rule the top-level `vars`
|
|
553
|
+
* has always followed.
|
|
554
|
+
*
|
|
555
|
+
* The node's own block is resolved **against the outer scope** before being installed, so
|
|
556
|
+
* `vars: { out: '${{ vars.x }}/dist' }` reads the `x` it is refining rather than itself.
|
|
557
|
+
*
|
|
558
|
+
* Installed as a plain property over the context's lazy top-level getter and restored afterwards -
|
|
559
|
+
* `walk` is depth-first and synchronous, so the window is exactly this subtree, and a value function
|
|
560
|
+
* called inside it reads the same object through its prototype.
|
|
561
|
+
*/
|
|
562
|
+
function withScopedVars(node, scope, context, at, skip, body) {
|
|
563
|
+
/**
|
|
564
|
+
* **A `vars` block does not scope itself.** Resolving one walks its own values, and without this
|
|
565
|
+
* that walk asks for the scope it is in the middle of producing - which the cycle guard catches
|
|
566
|
+
* and reports as `vars -> vars`. It recovered (the guard returns `undefined`, so the block simply
|
|
567
|
+
* saw no outer scope, which is what it should see anyway), but it left the cycle *flag* set, and
|
|
568
|
+
* the next genuine error in that key came out wearing `Config expression forms a cycle` - found by
|
|
569
|
+
* running a real shared config, whose `[...value]` mistake arrived with a loop attached that had
|
|
570
|
+
* nothing to do with it.
|
|
571
|
+
*
|
|
572
|
+
* Any path with a `vars` segment is inside a block: its contents are values, not config nodes.
|
|
573
|
+
*/
|
|
574
|
+
if (at.some(segment => segment === VARS_KEY))
|
|
575
|
+
return body();
|
|
576
|
+
const outer = context[VARS_KEY];
|
|
577
|
+
const own = node[VARS_KEY];
|
|
578
|
+
/** Nothing to shadow and nothing to protect: a node with no object below it can hold no function
|
|
579
|
+
* either, so the copy would be pure cost. */
|
|
580
|
+
if (own === undefined && !hasObjectChild(node))
|
|
581
|
+
return body();
|
|
582
|
+
const resolvedOwn = own === undefined ? undefined : walk(own, scope, context, [...at, VARS_KEY], skip);
|
|
583
|
+
const scoped = { ...outer, ...(isPlainObject(resolvedOwn) ? resolvedOwn : undefined) };
|
|
584
|
+
const previous = Object.getOwnPropertyDescriptor(context, VARS_KEY);
|
|
585
|
+
Object.defineProperty(context, VARS_KEY, { value: scoped, enumerable: true, configurable: true, writable: true });
|
|
586
|
+
try {
|
|
587
|
+
return body();
|
|
588
|
+
}
|
|
589
|
+
finally {
|
|
590
|
+
if (previous)
|
|
591
|
+
Object.defineProperty(context, VARS_KEY, previous);
|
|
592
|
+
else
|
|
593
|
+
delete context[VARS_KEY];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function hasObjectChild(node) {
|
|
597
|
+
for (const item of Object.values(node)) {
|
|
598
|
+
if (typeof item === 'function')
|
|
599
|
+
return true;
|
|
600
|
+
if (item && typeof item === 'object')
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
function isPlainObject(value) {
|
|
606
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
607
|
+
}
|
|
608
|
+
/** The one key that scopes rather than configures - see `withScopedVars`. Reserved at **every**
|
|
609
|
+
* level, which costs a script that would have been called `vars`: `run.vars` is a scope, not a
|
|
610
|
+
* script. Nothing enumerates `run`'s keys as a list of script names, so the cost stops there. */
|
|
611
|
+
const VARS_KEY = 'vars';
|
|
612
|
+
/**
|
|
613
|
+
* Whether a function at `at` is **code** - a step to run later, or part of a plugin - rather than a
|
|
614
|
+
* value to compute now.
|
|
615
|
+
*
|
|
616
|
+
* Array indices are dropped before matching, so a function inside a *list* of steps is still a
|
|
617
|
+
* step; `*` in a `STEP_PATHS` entry matches any one segment (`run.<script>.exec`).
|
|
618
|
+
*/
|
|
619
|
+
function isCodePath(at) {
|
|
620
|
+
const segments = at.filter((p) => typeof p === 'string');
|
|
621
|
+
if (CODE_SUBTREES.includes(segments[0]))
|
|
622
|
+
return true;
|
|
623
|
+
return STEP_PATHS.some(pattern => {
|
|
624
|
+
const parts = pattern.split('.');
|
|
625
|
+
return parts.length === segments.length && parts.every((part, i) => part === '*' || part === segments[i]);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Calls a **value** function: the JS spelling of a `${{ }}` expression, and it answers the same
|
|
630
|
+
* question at the same moment.
|
|
631
|
+
*
|
|
632
|
+
* It receives one object carrying everything an expression can name - `pkg`, `repository`, `file`,
|
|
633
|
+
* `env`, `semver`, `path`, plus the config's own top-level keys - and, in addition, **`value`**: what
|
|
634
|
+
* this key resolved to in the layers underneath, which is what makes a derived value possible
|
|
635
|
+
* without restating the base.
|
|
636
|
+
*
|
|
637
|
+
* Built with the interpolation context as its **prototype**, not copied from it. The top-level keys
|
|
638
|
+
* are lazy getters (`resolve`, memoized, so key order in the file means nothing and a cycle is
|
|
639
|
+
* reported rather than half-resolved); spreading them into a new object would fire every one of
|
|
640
|
+
* them on every call, including the ones a function never reads - and one of those throwing would
|
|
641
|
+
* blame the wrong key.
|
|
642
|
+
*
|
|
643
|
+
* **It must compute and return, never act.** This runs while the repository's config resolves,
|
|
644
|
+
* which *every* command does - so a value function that writes a file writes it on `rman list`,
|
|
645
|
+
* `rman info` and `rman config` too, N times for N packages, with no command having asked for
|
|
646
|
+
* anything. That is the same reason `FileScope` offers no way to change anything. Work goes in a
|
|
647
|
+
* step, which is the one thing rman runs on purpose and which can also be a function.
|
|
648
|
+
*/
|
|
649
|
+
function callValueFn(fn, scope, context, at, skip) {
|
|
650
|
+
const previous = fn[PREVIOUS_VALUE];
|
|
651
|
+
const arg = Object.create(context);
|
|
652
|
+
/** Resolved the same way any other value is, so an inherited `${{ }}` string or a function under
|
|
653
|
+
* it is already finished by the time this one is handed it. */
|
|
654
|
+
const resolvedPrevious = previous === undefined ? undefined : walk(previous, scope, context, at, skip);
|
|
655
|
+
/**
|
|
656
|
+
* A getter only so the catch below can tell whether the function **actually read `value`**.
|
|
657
|
+
*
|
|
658
|
+
* Without that, the "value is undefined" hint went out with *every* failure of a first-layer
|
|
659
|
+
* function - a frozen-object `TypeError` from `read()` arrived wearing advice about spreading an
|
|
660
|
+
* inherited list, which is precisely the send-the-reader-to-the-wrong-place mistake the hint
|
|
661
|
+
* exists to prevent. Recorded rather than inferred from the message, because matching on V8's
|
|
662
|
+
* wording is the other way to get this wrong.
|
|
663
|
+
*/
|
|
664
|
+
let valueRead = false;
|
|
665
|
+
Object.defineProperty(arg, 'value', {
|
|
666
|
+
enumerable: true,
|
|
667
|
+
get: () => {
|
|
668
|
+
valueRead = true;
|
|
669
|
+
return resolvedPrevious;
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
try {
|
|
673
|
+
return fn(arg);
|
|
674
|
+
}
|
|
675
|
+
catch (e) {
|
|
676
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
677
|
+
/**
|
|
678
|
+
* **`value` is `undefined` when no layer underneath set this key.** A function written to extend
|
|
679
|
+
* an inherited list (`[...value, x]`) is also the *first* layer in a repository that inherits
|
|
680
|
+
* nothing, and V8's report for that is `value is not iterable` - which names neither the key nor
|
|
681
|
+
* the reason, and sends the reader looking at their spread instead of at what is missing.
|
|
682
|
+
*
|
|
683
|
+
* Told rather than papered over: defaulting `value` to `[]` would be a guess about the key's
|
|
684
|
+
* type, and wrong for every key that is not a list.
|
|
685
|
+
*/
|
|
686
|
+
const hint = valueRead && resolvedPrevious === undefined
|
|
687
|
+
? `\n \`value\` is undefined here - nothing below this layer sets "${where}".` +
|
|
688
|
+
`\n Write \`value ?? []\` (or \`?? ''\`) if the function has to work as the first layer too.`
|
|
689
|
+
: '';
|
|
690
|
+
throw new Error(`Config function in "${where}" failed: ${e?.message}${hint}`, { cause: e });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
244
693
|
function interpolateString(value, context, at) {
|
|
245
694
|
if (!value.includes('${{'))
|
|
246
695
|
return value;
|
|
@@ -284,3 +733,129 @@ function formatPath(at) {
|
|
|
284
733
|
/** Guards against an expression that never returns (`while(true)`) taking the whole command with
|
|
285
734
|
* it - a typo, not an attack, but the failure mode is identical. */
|
|
286
735
|
const EXPRESSION_TIMEOUT = 1000;
|
|
736
|
+
/**
|
|
737
|
+
* Reads and parses one structured file, memoized against **the identity of its contents** rather
|
|
738
|
+
* than its path alone: the cache key is `mtimeNs:size`.
|
|
739
|
+
*
|
|
740
|
+
* Both halves of that were chosen against a measurement.
|
|
741
|
+
*
|
|
742
|
+
* - **A stat rather than a re-read**: `statSync` is 1.3µs where `readFileSync` + `JSON.parse` is
|
|
743
|
+
* 16.1µs on a 2KB manifest - so the check costs a thirteenth of what it saves, and the same file
|
|
744
|
+
* read by twenty packages is parsed once. (`interpolateConfig` runs once *per package*, so a
|
|
745
|
+
* cache living in one pass would not have helped across them at all.)
|
|
746
|
+
* - **Keyed on the stat rather than held for the run**: rman writes JSON files while it is running
|
|
747
|
+
* - `version` rewrites every bumped manifest, then re-interpolates its own deferred hooks. A
|
|
748
|
+
* cache that only remembered the path would hand those back as they were before the write.
|
|
749
|
+
* `mtimeNs` is nanoseconds, so a rewrite within the same millisecond does not slip through; the
|
|
750
|
+
* size is in the key as well because it costs nothing.
|
|
751
|
+
*
|
|
752
|
+
* **Frozen, deeply, once on the way into the cache.** Every package is handed the same object, so
|
|
753
|
+
* one config mutating it would quietly change what the next package sees - the reason `pkg.manifest`
|
|
754
|
+
* has always been a copy. Freezing is better than copying here: a copy costs 5.6µs on *every* call,
|
|
755
|
+
* freezing costs ~1µs *once*, and it turns the mistake into a `TypeError` instead of an effect at a
|
|
756
|
+
* distance. A caller that wants to change something spreads it first.
|
|
757
|
+
*/
|
|
758
|
+
function readStructuredFile(file, format, cache) {
|
|
759
|
+
let stat;
|
|
760
|
+
try {
|
|
761
|
+
stat = fs.statSync(file, { bigint: true });
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
throw new Error(`read("${path.basename(file)}") found nothing at ${file}\n` +
|
|
765
|
+
` Use file.exists() first if its absence is a case to handle rather than a mistake.`);
|
|
766
|
+
}
|
|
767
|
+
if (stat.isDirectory())
|
|
768
|
+
throw new Error(`read() was given a directory, not a file: ${file}`);
|
|
769
|
+
const stamp = `${stat.mtimeNs}:${stat.size}`;
|
|
770
|
+
const cached = cache.get(file);
|
|
771
|
+
if (cached?.stamp === stamp)
|
|
772
|
+
return cached.value;
|
|
773
|
+
const resolved = format ?? formatOf(file);
|
|
774
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
775
|
+
let value;
|
|
776
|
+
try {
|
|
777
|
+
value = parseStructured(text, resolved);
|
|
778
|
+
}
|
|
779
|
+
catch (e) {
|
|
780
|
+
/** The parser's own message says what is wrong with the syntax but never which file it was
|
|
781
|
+
* reading - and an expression can name several. */
|
|
782
|
+
throw new Error(`read("${path.basename(file)}") could not parse ${file} as ${resolved}: ${e?.message}`, {
|
|
783
|
+
cause: e,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
deepFreeze(value);
|
|
787
|
+
cache.set(file, { stamp, value });
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
/** The extension decides, because the caller already wrote it - naming the parser as well would
|
|
791
|
+
* restate it and let the two disagree (`json("x.yml")`). A name that says nothing takes the
|
|
792
|
+
* explicit argument instead. */
|
|
793
|
+
function formatOf(file) {
|
|
794
|
+
const ext = path.extname(file).toLowerCase();
|
|
795
|
+
if (ext === '.json')
|
|
796
|
+
return 'json';
|
|
797
|
+
if (ext === '.yml' || ext === '.yaml')
|
|
798
|
+
return 'yaml';
|
|
799
|
+
if (ext === '.ini')
|
|
800
|
+
return 'ini';
|
|
801
|
+
if (XML_EXTENSIONS.has(ext))
|
|
802
|
+
return 'xml';
|
|
803
|
+
throw new Error(`read() cannot tell what "${path.basename(file)}" is from its name.\n` +
|
|
804
|
+
` Name the format: read("${path.basename(file)}", "json" | "yaml" | "ini" | "xml").`);
|
|
805
|
+
}
|
|
806
|
+
/** The XML family worth recognizing by name: a project file is XML whatever its extension calls
|
|
807
|
+
* itself, and `.csproj`/`.pom` are what a .NET or Maven repository actually holds. Anything else
|
|
808
|
+
* still reads with an explicit `read(p, 'xml')`. */
|
|
809
|
+
const XML_EXTENSIONS = new Set(['.xml', '.csproj', '.vbproj', '.fsproj', '.props', '.targets', '.nuspec', '.plist']);
|
|
810
|
+
function parseStructured(text, format) {
|
|
811
|
+
if (format === 'json')
|
|
812
|
+
return JSON.parse(text);
|
|
813
|
+
/** `load`, not `loadAll`: a multi-document stream has no single value to be, and js-yaml says so
|
|
814
|
+
* clearly enough ("expected a single document in the stream") to leave alone. */
|
|
815
|
+
if (format === 'yaml')
|
|
816
|
+
return yaml.load(text);
|
|
817
|
+
if (format === 'xml')
|
|
818
|
+
return parseXml(text);
|
|
819
|
+
return ini.parse(text);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* A **DOM**, not an object - and the asymmetry with the other three formats is the honest shape
|
|
823
|
+
* rather than an omission.
|
|
824
|
+
*
|
|
825
|
+
* XML has no lossless object form: an element can repeat, carry attributes and hold text at the
|
|
826
|
+
* same time, so any flattening has to pick a convention (`$`? `_text`? array-or-not?) and be wrong
|
|
827
|
+
* for somebody. A DOM is the shape XML actually has, so a config reads it the way every other XML
|
|
828
|
+
* tool does:
|
|
829
|
+
*
|
|
830
|
+
* ```yaml
|
|
831
|
+
* version: '${{ read("pom.xml").getElementsByTagName("version")[0].textContent }}'
|
|
832
|
+
* ```
|
|
833
|
+
*
|
|
834
|
+
* **Freezing it is safe** - measured, not assumed: a frozen `@xmldom/xmldom` document still answers
|
|
835
|
+
* `getElementsByTagName` for a tag first asked about *after* the freeze (the live-collection case
|
|
836
|
+
* that would have broken it), reads attributes, resolves namespaces, walks `childNodes` and
|
|
837
|
+
* serialises back.
|
|
838
|
+
*/
|
|
839
|
+
function parseXml(text) {
|
|
840
|
+
/** xmldom reports a malformed document through a handler and otherwise carries on with whatever
|
|
841
|
+
* it could salvage - so without this, a broken file would come back as a half-parsed DOM and the
|
|
842
|
+
* expression reading it would simply find nothing. `read()` throws for a broken JSON file; it has
|
|
843
|
+
* to throw for this one too. */
|
|
844
|
+
const problems = [];
|
|
845
|
+
const doc = new DOMParser({
|
|
846
|
+
onError: (level, message) => {
|
|
847
|
+
if (level !== 'warning')
|
|
848
|
+
problems.push(message.split('\n')[0]);
|
|
849
|
+
},
|
|
850
|
+
}).parseFromString(text, 'text/xml');
|
|
851
|
+
if (problems.length)
|
|
852
|
+
throw new Error(problems[0]);
|
|
853
|
+
return doc;
|
|
854
|
+
}
|
|
855
|
+
function deepFreeze(value) {
|
|
856
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
857
|
+
return;
|
|
858
|
+
Object.freeze(value);
|
|
859
|
+
for (const item of Object.values(value))
|
|
860
|
+
deepFreeze(item);
|
|
861
|
+
}
|