rman 1.0.10 → 1.1.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 +116 -85
- package/cli.js +225 -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 +60 -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 +257 -6
- package/core/config.js +409 -17
- 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 +57 -0
- package/core/merge-config.js +146 -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 +85 -4
- package/core/repository.js +258 -81
- package/core/resolve-target.d.ts +12 -0
- package/core/resolve-target.js +33 -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 +54 -8
- package/index.js +42 -7
- package/interfaces/rman-config.interface.d.ts +226 -28
- package/package.json +15 -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 +68 -3
- package/services/run.service.js +162 -70
- 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 +92 -82
- package/services/version.service.js +233 -383
- 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/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 +48 -0
- package/utils/version-stamp.js +88 -0
- 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 -375
- 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 -207
- 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
|
@@ -2,8 +2,11 @@ import fs from 'fs';
|
|
|
2
2
|
import * as yaml from 'js-yaml';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import
|
|
5
|
+
import semver from 'semver';
|
|
6
6
|
import { pathToFileURL } from 'url';
|
|
7
|
+
import vm from 'vm';
|
|
8
|
+
import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
|
|
9
|
+
import { finalizeConfig, mergeConfig } from './merge-config.js';
|
|
7
10
|
/**
|
|
8
11
|
* Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
|
|
9
12
|
* autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
|
|
@@ -56,53 +59,225 @@ async function loadJsConfig(file) {
|
|
|
56
59
|
*/
|
|
57
60
|
export async function readDirConfig(dirname) {
|
|
58
61
|
const result = {};
|
|
62
|
+
/** The file an `extends` in this directory resolves relative to. The last form that actually
|
|
63
|
+
* declared one wins, which matters only for the unusual directory holding several. */
|
|
64
|
+
let extendsFrom = path.join(dirname, '.rmanrc');
|
|
59
65
|
const pkgJsonFile = path.join(dirname, 'package.json');
|
|
60
66
|
if (fs.existsSync(pkgJsonFile)) {
|
|
61
67
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
|
|
62
|
-
if (pkgJson && typeof pkgJson.rman === 'object')
|
|
63
|
-
|
|
68
|
+
if (pkgJson && typeof pkgJson.rman === 'object') {
|
|
69
|
+
assertNoSelectorExtends(pkgJson.rman, pkgJsonFile);
|
|
70
|
+
if (EXTENDS_KEY in pkgJson.rman)
|
|
71
|
+
extendsFrom = pkgJsonFile;
|
|
72
|
+
mergeConfig(result, pkgJson.rman);
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
const ymlFile = path.join(dirname, '.rmanrc.yml');
|
|
66
76
|
if (fs.existsSync(ymlFile)) {
|
|
67
77
|
const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
|
|
68
|
-
if (obj && typeof obj === 'object')
|
|
69
|
-
|
|
78
|
+
if (obj && typeof obj === 'object') {
|
|
79
|
+
assertNoSelectorExtends(obj, ymlFile);
|
|
80
|
+
if (EXTENDS_KEY in obj)
|
|
81
|
+
extendsFrom = ymlFile;
|
|
82
|
+
mergeConfig(result, obj);
|
|
83
|
+
}
|
|
70
84
|
}
|
|
71
85
|
const rcFile = path.join(dirname, '.rmanrc');
|
|
72
86
|
if (fs.existsSync(rcFile)) {
|
|
73
87
|
const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
|
|
74
|
-
if (obj && typeof obj === 'object')
|
|
75
|
-
|
|
88
|
+
if (obj && typeof obj === 'object') {
|
|
89
|
+
assertNoSelectorExtends(obj, rcFile);
|
|
90
|
+
if (EXTENDS_KEY in obj)
|
|
91
|
+
extendsFrom = rcFile;
|
|
92
|
+
mergeConfig(result, obj);
|
|
93
|
+
}
|
|
76
94
|
}
|
|
77
95
|
for (const jsFileName of JS_CONFIG_FILES) {
|
|
78
96
|
const jsFile = path.join(dirname, jsFileName);
|
|
79
97
|
if (fs.existsSync(jsFile)) {
|
|
80
98
|
const obj = await loadJsConfig(jsFile);
|
|
81
|
-
if (obj && typeof obj === 'object')
|
|
82
|
-
|
|
99
|
+
if (obj && typeof obj === 'object') {
|
|
100
|
+
assertNoSelectorExtends(obj, jsFile);
|
|
101
|
+
if (EXTENDS_KEY in obj)
|
|
102
|
+
extendsFrom = jsFile;
|
|
103
|
+
mergeConfig(result, obj);
|
|
104
|
+
}
|
|
83
105
|
}
|
|
84
106
|
}
|
|
85
|
-
|
|
107
|
+
/** Resolved per directory, once its own forms have been combined: `extends` is the base every
|
|
108
|
+
* one of them sits on, and the directory chain then layers on top as it always did. Each form
|
|
109
|
+
* was checked for a misplaced `extends` as it was read, so that error can name the file holding
|
|
110
|
+
* it rather than whichever form happened to declare the real one. */
|
|
111
|
+
return resolveExtends(result, extendsFrom);
|
|
86
112
|
}
|
|
87
113
|
/**
|
|
88
|
-
* Resolves the effective config for `targetDir
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
114
|
+
* Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
|
|
115
|
+
* it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
|
|
116
|
+
* chain does.
|
|
117
|
+
*
|
|
118
|
+
* Every level contributes in two ways, and the difference is the whole model:
|
|
119
|
+
*
|
|
120
|
+
* - **Unmarked keys configure the package of the directory that declares them.** The root's own
|
|
121
|
+
* `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
|
|
122
|
+
* (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
|
|
123
|
+
* not, silently, every package under it.
|
|
124
|
+
* - **A `"[selector]"` block configures the packages it names** - `"[*]"` for all of them (the root
|
|
125
|
+
* included), `"[ws:*]"` for every one but the root, `"[/]"` for the root alone, `"[*-dialect]"`
|
|
126
|
+
* for a glob over package names. See `parseSelector`. This is the only way a directory speaks
|
|
127
|
+
* about anything but its own package.
|
|
128
|
+
*
|
|
129
|
+
* Splitting the two matters because the same key means different things to the two audiences. The
|
|
130
|
+
* clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
|
|
131
|
+
* its own directory; on the root it's a repo-wide bookend run once at the repository root. A
|
|
132
|
+
* cascade that fed one declaration to both ran a package-relative command (`node
|
|
133
|
+
* ../../support/postbuild.cjs`) at the root, where it cannot resolve.
|
|
134
|
+
*
|
|
135
|
+
* `packageName` is what selectors match against; without it, selector blocks contribute nothing at
|
|
136
|
+
* all. The root package passes its own, since `"[/]"` and `"[*]"` speak to it.
|
|
93
137
|
*/
|
|
94
|
-
export async function resolveConfig(rootDir, targetDir, cache = new Map()) {
|
|
138
|
+
export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
|
|
95
139
|
const result = {};
|
|
140
|
+
const target = path.resolve(targetDir);
|
|
141
|
+
/** The root *package* is the one whose directory is the repository root - no other test is
|
|
142
|
+
* needed, and none would be as reliable: a name can be anything. In a single-package repository
|
|
143
|
+
* that is the only package, so `"[/]"` reaches it and `"[ws:*]"` reaches nothing. */
|
|
144
|
+
const isRoot = target === path.resolve(rootDir);
|
|
96
145
|
for (const dir of dirChain(rootDir, targetDir)) {
|
|
97
146
|
let local = cache.get(dir);
|
|
98
147
|
if (!local) {
|
|
99
148
|
local = await readDirConfig(dir);
|
|
100
149
|
cache.set(dir, local);
|
|
101
150
|
}
|
|
102
|
-
|
|
151
|
+
// A directory holding a package speaks for that package only - which is what keeps the root's
|
|
152
|
+
// own config off every package under it. A directory that holds none (an intermediate
|
|
153
|
+
// `packages/`, say) has no package to speak for, so its unmarked config can only mean
|
|
154
|
+
// "everything below" and still cascades.
|
|
155
|
+
const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
|
|
156
|
+
const speaksForTarget = !ownsAPackage || path.resolve(dir) === target;
|
|
157
|
+
/**
|
|
158
|
+
* `vars` is the **one** unmarked key that cascades past the package its directory speaks for,
|
|
159
|
+
* and it is not a hole in that rule - it is a key the rule was never about. The rule exists
|
|
160
|
+
* because a setting means different things to the two audiences (`run.build.after` on the root
|
|
161
|
+
* is a repo-wide bookend, on a package its own hook), so one declaration cannot serve both.
|
|
162
|
+
* `vars: {x: 1}` means the number 1 to everyone; there is no second audience to be wrong for.
|
|
163
|
+
*
|
|
164
|
+
* Merged *before* this directory's selector blocks, so `"[*]": {vars: ...}` - which names the
|
|
165
|
+
* packages explicitly - overrides the same directory's plainer statement.
|
|
166
|
+
*/
|
|
167
|
+
if (!speaksForTarget && local.vars !== undefined)
|
|
168
|
+
mergeConfig(result, { vars: local.vars });
|
|
169
|
+
// Selectors next, so a directory's own unmarked config still wins over a selector declared
|
|
170
|
+
// alongside it - "this package" is a more specific statement than "packages matching a glob".
|
|
171
|
+
if (packageName) {
|
|
172
|
+
for (const block of matchingSelectors(local, packageName, isRoot))
|
|
173
|
+
mergeConfig(result, block);
|
|
174
|
+
}
|
|
175
|
+
if (speaksForTarget)
|
|
176
|
+
mergeConfig(result, stripSelectors(local));
|
|
103
177
|
}
|
|
178
|
+
/** Every layer has had its turn, so an append still outstanding has nothing left to attach to
|
|
179
|
+
* and becomes the value itself. Done here rather than per layer: until the chain is finished,
|
|
180
|
+
* the key it appends to may still be coming. */
|
|
181
|
+
return finalizeConfig(result);
|
|
182
|
+
}
|
|
183
|
+
/** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[ws:*]"`, `"[pkg-a]"`. The
|
|
184
|
+
* brackets are what keep this space from colliding with real config keys - no setting starts with
|
|
185
|
+
* one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
|
|
186
|
+
* parses as a flow sequence. */
|
|
187
|
+
export function isSelectorKey(key) {
|
|
188
|
+
return key.length > 2 && key.startsWith('[') && key.endsWith(']');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* **Which packages a selector speaks for.** Three audiences, because a repository has three:
|
|
192
|
+
*
|
|
193
|
+
* | | |
|
|
194
|
+
* | --- | --- |
|
|
195
|
+
* | `"[/]"` | the **root package** only |
|
|
196
|
+
* | `"[*]"`, `"[pkg-a]"`, `"[*-dialect]"` | **every** package the glob matches, root included |
|
|
197
|
+
* | `"[ws:*]"`, `"[workspace:pkg-*]"` | every **non-root** package the glob matches |
|
|
198
|
+
*
|
|
199
|
+
* `/` for the root because that is what a repository root is called everywhere else, and it cannot
|
|
200
|
+
* collide with a package name. `ws:` is a qualifier on the glob rather than a separate spelling of
|
|
201
|
+
* `*`, so `"[ws:pkg-*]"` means what it looks like.
|
|
202
|
+
*
|
|
203
|
+
* **`"[*]"` includes the root, and that is a change from how it used to read.** Before, selectors
|
|
204
|
+
* were not applied to the root at all, so `"[*]"` silently meant "the workspace packages" - a
|
|
205
|
+
* catch-all with an exception nothing in the syntax mentioned. The three names above say which
|
|
206
|
+
* audience is meant; `"[ws:*]"` is the old behaviour, now spelled.
|
|
207
|
+
*/
|
|
208
|
+
export function parseSelector(key) {
|
|
209
|
+
const inner = key.slice(1, -1);
|
|
210
|
+
if (inner === ROOT_SELECTOR_INNER)
|
|
211
|
+
return { scope: 'root', test: () => true };
|
|
212
|
+
for (const prefix of WORKSPACE_PREFIXES) {
|
|
213
|
+
if (inner.startsWith(prefix)) {
|
|
214
|
+
const re = globToRegExp(inner.slice(prefix.length));
|
|
215
|
+
return { scope: 'workspace', test: name => re.test(name) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const re = globToRegExp(inner);
|
|
219
|
+
return { scope: 'all', test: name => re.test(name) };
|
|
220
|
+
}
|
|
221
|
+
/** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
|
|
222
|
+
* `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
|
|
223
|
+
* pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
|
|
224
|
+
export function selectorToRegExp(key) {
|
|
225
|
+
return globToRegExp(key.slice(1, -1));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Every selector block in `config` that speaks for this package, in increasing precedence.
|
|
229
|
+
*
|
|
230
|
+
* Order, lowest first: **`"[*]"`, then a catch-all `"[ws:*]"`, then the rest in declaration
|
|
231
|
+
* order** - so narrowing the audience wins over the widest one, a named package or `"[/]"` wins
|
|
232
|
+
* over both, and two equally specific globs resolve by the order they were written in. A catch-all
|
|
233
|
+
* is ranked rather than left to declaration order on purpose: where you happen to write "everything"
|
|
234
|
+
* should not decide whether it beats a rule about one package.
|
|
235
|
+
*/
|
|
236
|
+
function matchingSelectors(config, packageName, isRoot) {
|
|
237
|
+
const matches = [];
|
|
238
|
+
for (const [key, value] of Object.entries(config)) {
|
|
239
|
+
if (!isSelectorKey(key) || !value || typeof value !== 'object')
|
|
240
|
+
continue;
|
|
241
|
+
const { scope, test } = parseSelector(key);
|
|
242
|
+
if (scope === 'root' && !isRoot)
|
|
243
|
+
continue;
|
|
244
|
+
if (scope === 'workspace' && isRoot)
|
|
245
|
+
continue;
|
|
246
|
+
if (!test(packageName))
|
|
247
|
+
continue;
|
|
248
|
+
matches.push([selectorRank(key), value]);
|
|
249
|
+
}
|
|
250
|
+
return matches.sort((a, b) => a[0] - b[0]).map(([, block]) => block);
|
|
251
|
+
}
|
|
252
|
+
/** 0 for `"[*]"`, 1 for a catch-all workspace selector, 2 for anything that names something. Equal
|
|
253
|
+
* ranks keep their declaration order, since `Array.prototype.sort` is stable. */
|
|
254
|
+
function selectorRank(key) {
|
|
255
|
+
if (key === CATCH_ALL)
|
|
256
|
+
return 0;
|
|
257
|
+
const inner = key.slice(1, -1);
|
|
258
|
+
return WORKSPACE_PREFIXES.some(prefix => inner === `${prefix}*`) ? 1 : 2;
|
|
259
|
+
}
|
|
260
|
+
function globToRegExp(glob) {
|
|
261
|
+
const source = glob
|
|
262
|
+
.split('*')
|
|
263
|
+
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
264
|
+
.join('.*');
|
|
265
|
+
return new RegExp(`^${source}$`);
|
|
266
|
+
}
|
|
267
|
+
function stripSelectors(config) {
|
|
268
|
+
const result = {};
|
|
269
|
+
for (const [key, value] of Object.entries(config))
|
|
270
|
+
if (!isSelectorKey(key))
|
|
271
|
+
result[key] = value;
|
|
104
272
|
return result;
|
|
105
273
|
}
|
|
274
|
+
const CATCH_ALL = '[*]';
|
|
275
|
+
/** `"[/]"` - the root package, spelled the way a repository root is spelled everywhere else, and
|
|
276
|
+
* unable to collide with a package name. */
|
|
277
|
+
const ROOT_SELECTOR_INNER = '/';
|
|
278
|
+
/** Both spellings of "the workspace packages, not the root". The long one reads in a config file
|
|
279
|
+
* someone else has to understand; the short one is what gets typed. */
|
|
280
|
+
const WORKSPACE_PREFIXES = ['workspace:', 'ws:'];
|
|
106
281
|
function dirChain(rootDir, targetDir) {
|
|
107
282
|
const rel = path.relative(rootDir, targetDir);
|
|
108
283
|
if (!rel || rel === '.' || rel.startsWith('..'))
|
|
@@ -115,3 +290,220 @@ function dirChain(rootDir, targetDir) {
|
|
|
115
290
|
}
|
|
116
291
|
return dirs;
|
|
117
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
|
|
295
|
+
* the package it was resolved for:
|
|
296
|
+
*
|
|
297
|
+
* ```yaml
|
|
298
|
+
* "[*]":
|
|
299
|
+
* clean:
|
|
300
|
+
* include: ["build", "../../coverage/${{ pkg.basename }}"]
|
|
301
|
+
* publish:
|
|
302
|
+
* directory: build
|
|
303
|
+
* docker:
|
|
304
|
+
* image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
|
|
305
|
+
* run:
|
|
306
|
+
* build:
|
|
307
|
+
* # the config's own keys are in scope, so this is not a second copy of "build"
|
|
308
|
+
* after: "cp README.md ${{ publish.directory }}/"
|
|
309
|
+
* ```
|
|
310
|
+
*
|
|
311
|
+
* Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
|
|
312
|
+
* nobody remembers.
|
|
313
|
+
*
|
|
314
|
+
* The contents are **real JavaScript**, not a template mini-language, so there is no growing list
|
|
315
|
+
* of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
|
|
316
|
+
* in scope.
|
|
317
|
+
*
|
|
318
|
+
* **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
|
|
319
|
+
* something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
|
|
320
|
+
* rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
|
|
321
|
+
* Actions does: `${{ '${{' }}`.
|
|
322
|
+
*
|
|
323
|
+
* A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
|
|
324
|
+
* -> a boolean), since otherwise this could only ever produce strings and settings like
|
|
325
|
+
* `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
|
|
326
|
+
*
|
|
327
|
+
* Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
|
|
328
|
+
* scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
|
|
329
|
+
* called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
|
|
330
|
+
* the expression evaluator adds no trust boundary that wasn't already wide open.
|
|
331
|
+
*
|
|
332
|
+
* A failing expression throws with the config path that holds it, rather than being left in place:
|
|
333
|
+
* silently passing through a mistake is how a config ends up quietly doing nothing.
|
|
334
|
+
*/
|
|
335
|
+
export function interpolateConfig(config, scope, options) {
|
|
336
|
+
const skip = options?.skip ?? [];
|
|
337
|
+
const context = vm.createContext({ ...scope });
|
|
338
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
339
|
+
return walk(config, scope, context, [], skip);
|
|
340
|
+
/**
|
|
341
|
+
* The config's own top-level keys, readable bare: `${{ publish.directory }}`. So a value that
|
|
342
|
+
* restates another - `after: "cp README.md ${{ publish.directory }}/"` - stops being a second
|
|
343
|
+
* copy that drifts when the first one changes.
|
|
344
|
+
*
|
|
345
|
+
* Resolved **on demand**, one key at a time, and memoized. Interpolating the config in tree order
|
|
346
|
+
* and handing an expression whatever was ready would make the answer depend on key order in the
|
|
347
|
+
* file, which is exactly the kind of quiet wrongness this evaluator exists to prevent: a key
|
|
348
|
+
* declared above would read as resolved and one below as raw. On demand, each key is resolved
|
|
349
|
+
* when first read and the order in the file means nothing.
|
|
350
|
+
*/
|
|
351
|
+
const resolved = new Map();
|
|
352
|
+
const resolving = [];
|
|
353
|
+
/**
|
|
354
|
+
* A cycle is **recorded here rather than thrown from the getter**, and that is not a style
|
|
355
|
+
* choice: a host getter that throws inside a `vm` property interceptor has its exception
|
|
356
|
+
* swallowed, and V8 then reports the global as absent - so a self-referencing key came out as
|
|
357
|
+
* `publish is not defined`, which sends the reader looking for a missing key instead of a loop
|
|
358
|
+
* (measured). The getter returns `undefined`, the resulting `ReferenceError` is caught below, and
|
|
359
|
+
* this replaces it.
|
|
360
|
+
*/
|
|
361
|
+
let cycle;
|
|
362
|
+
const resolve = (key) => {
|
|
363
|
+
if (resolved.has(key))
|
|
364
|
+
return resolved.get(key);
|
|
365
|
+
if (resolving.includes(key)) {
|
|
366
|
+
cycle ??= new Error(`Config expression forms a cycle: ${[...resolving, key].join(' -> ')}\n` +
|
|
367
|
+
` A value cannot be derived from itself, directly or through another key.`);
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
resolving.push(key);
|
|
371
|
+
try {
|
|
372
|
+
const value = walk(config[key], scope, context, [key], skip);
|
|
373
|
+
resolved.set(key, value);
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
/** Not cleared: a cycle aborts the whole interpolation, and each level up would otherwise
|
|
378
|
+
* re-swallow its own replacement the same way - leaving it set lets the outermost frame,
|
|
379
|
+
* the one with a real stack to throw from, report it. */
|
|
380
|
+
if (cycle)
|
|
381
|
+
throw new Error(`${String(e?.message).split('\n')[0]}\n ${cycle.message}`, { cause: e });
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
resolving.pop();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
for (const key of Object.keys(config)) {
|
|
389
|
+
/** A scope binding wins: `pkg`/`repository`/`env`/`semver` are not config keys, so nothing
|
|
390
|
+
* collides today, and a future key that did must not silently take over the namespace. */
|
|
391
|
+
if (key in scope || !IDENTIFIER.test(key))
|
|
392
|
+
continue;
|
|
393
|
+
Object.defineProperty(context, key, { enumerable: true, configurable: true, get: () => resolve(key) });
|
|
394
|
+
}
|
|
395
|
+
/** Built through the same memo the getters use, so every key is walked exactly once whether an
|
|
396
|
+
* expression asked for it first or the result did. */
|
|
397
|
+
const result = {};
|
|
398
|
+
for (const key of Object.keys(config))
|
|
399
|
+
result[key] = resolve(key);
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Config paths left untouched when a repository's config is first resolved, and evaluated only by
|
|
404
|
+
* the command that runs them.
|
|
405
|
+
*
|
|
406
|
+
* `version`'s own hooks are the one place `${{ pkg.targetVersion }}` makes sense, and the version
|
|
407
|
+
* being written is not known until `version` has computed its plan - long after the config was
|
|
408
|
+
* resolved. Evaluating these eagerly would throw while merely *loading* the repository, so any
|
|
409
|
+
* command at all would fail on a config that mentions it.
|
|
410
|
+
*/
|
|
411
|
+
export const DEFERRED_PATHS = ['version.before', 'version.exec', 'version.after'];
|
|
412
|
+
const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
|
|
413
|
+
/** A config key an expression could actually name. Anything else - a `"[selector]"` block, a
|
|
414
|
+
* `"lint:fix"` - is unreachable as a bare identifier anyway, so it is not bound. */
|
|
415
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
416
|
+
/** The `file` namespace for one package's directory - see `FileScope`. */
|
|
417
|
+
export function createFileScope(dirname) {
|
|
418
|
+
const locate = (target) => {
|
|
419
|
+
if (typeof target !== 'string' || !target.trim()) {
|
|
420
|
+
throw new Error('file.exists()/file.resolve() need a path - they were given ' + JSON.stringify(target));
|
|
421
|
+
}
|
|
422
|
+
const resolved = path.resolve(dirname, target);
|
|
423
|
+
return { path: resolved, found: fs.existsSync(resolved) };
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
exists(target) {
|
|
427
|
+
const { path: resolved, found } = locate(target);
|
|
428
|
+
return found ? resolved : '';
|
|
429
|
+
},
|
|
430
|
+
resolve(target) {
|
|
431
|
+
const { path: resolved, found } = locate(target);
|
|
432
|
+
if (!found) {
|
|
433
|
+
throw new Error(`file.resolve("${target}") found nothing at ${resolved}\n` +
|
|
434
|
+
` Use file.exists() instead if its absence is a case to handle rather than a mistake.`);
|
|
435
|
+
}
|
|
436
|
+
return resolved;
|
|
437
|
+
},
|
|
438
|
+
resolveFirst(...targets) {
|
|
439
|
+
if (!targets.length)
|
|
440
|
+
throw new Error('file.resolveFirst() needs at least one path');
|
|
441
|
+
for (const target of targets) {
|
|
442
|
+
const { path: resolved, found } = locate(target);
|
|
443
|
+
if (found)
|
|
444
|
+
return resolved;
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`file.resolveFirst() found none of: ${targets.map(t => `"${t}"`).join(', ')}\n` + ` Looked in ${dirname}.`);
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function walk(value, scope, context, at, skip) {
|
|
451
|
+
/** Compared on the key path rather than the value, so a deferred key's whole subtree - a single
|
|
452
|
+
* command or an array of them - is handed on untouched. */
|
|
453
|
+
if (at.length && skip.includes(at.filter(p => typeof p === 'string').join('.')))
|
|
454
|
+
return value;
|
|
455
|
+
if (typeof value === 'string')
|
|
456
|
+
return interpolateString(value, context, at);
|
|
457
|
+
if (Array.isArray(value))
|
|
458
|
+
return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
|
|
459
|
+
if (value && typeof value === 'object') {
|
|
460
|
+
const result = {};
|
|
461
|
+
for (const [key, item] of Object.entries(value))
|
|
462
|
+
result[key] = walk(item, scope, context, [...at, key], skip);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
function interpolateString(value, context, at) {
|
|
468
|
+
if (!value.includes('${{'))
|
|
469
|
+
return value;
|
|
470
|
+
const found = [...value.matchAll(EXPRESSION)];
|
|
471
|
+
if (!found.length)
|
|
472
|
+
return value;
|
|
473
|
+
/** Counted rather than matched with an anchored `^...$` regex: a lazy quantifier still backtracks
|
|
474
|
+
* to satisfy an end anchor, so `"${{ a }} and ${{ b }}"` looked like *one* expression whose body
|
|
475
|
+
* ran from `a` to `b`, brace-ends and all - invalid JavaScript. */
|
|
476
|
+
const soleExpression = found.length === 1 && found[0][0] === value.trim();
|
|
477
|
+
// Alone, a nullish result is just "this setting is unset" - a legitimate answer.
|
|
478
|
+
if (soleExpression)
|
|
479
|
+
return evaluate(found[0][1], value, context, at);
|
|
480
|
+
return value.replace(EXPRESSION, (_, expr) => {
|
|
481
|
+
const result = evaluate(expr, value, context, at);
|
|
482
|
+
/** Embedded in text, though, it never is: splicing in the word "undefined" produces a path or
|
|
483
|
+
* tag like `app:undefined` that looks plausible and is wrong - the exact silent-mistake shape
|
|
484
|
+
* this evaluator exists to avoid. `?? 'fallback'` says what was meant. */
|
|
485
|
+
if (result === undefined || result === null) {
|
|
486
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
487
|
+
throw new Error(`Expression in "${where}" is ${result} inside a string: ${value.trim()}\n` +
|
|
488
|
+
` \${{${expr}}} has no value here - give it a fallback (\${{${expr.trim()} ?? '...'}}).`);
|
|
489
|
+
}
|
|
490
|
+
return String(result);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/** Names the config path as well as the expression: an error saying only "x is not defined" sends
|
|
494
|
+
* the reader hunting through a file that may hold dozens of them. */
|
|
495
|
+
function evaluate(expr, source, context, at) {
|
|
496
|
+
try {
|
|
497
|
+
return vm.runInContext(expr, context, { timeout: EXPRESSION_TIMEOUT });
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
const where = at.length ? formatPath(at) : 'the config root';
|
|
501
|
+
throw new Error(`Invalid expression in "${where}": ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function formatPath(at) {
|
|
505
|
+
return at.reduce((acc, part) => (typeof part === 'number' ? `${acc}[${part}]` : acc ? `${acc}.${part}` : String(part)), '');
|
|
506
|
+
}
|
|
507
|
+
/** Guards against an expression that never returns (`while(true)`) taking the whole command with
|
|
508
|
+
* it - a typo, not an attack, but the failure mode is identical. */
|
|
509
|
+
const EXPRESSION_TIMEOUT = 1000;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ArgumentsCamelCase, Argv } from 'yargs';
|
|
2
|
+
import type { Logger } from '../utils/logger.js';
|
|
3
|
+
import type { RunBinOptions, RunBinResult } from '../utils/run-bin.js';
|
|
4
|
+
import type { Package } from './package.js';
|
|
5
|
+
import type { Repository } from './repository.js';
|
|
6
|
+
/** Where a repository keeps its own commands - one module per command, named after it. */
|
|
7
|
+
export declare const CUSTOM_COMMAND_DIR = ".rman";
|
|
8
|
+
/**
|
|
9
|
+
* What a repository's own command is handed. An object rather than loose parameters so later
|
|
10
|
+
* additions don't break every command already written against it.
|
|
11
|
+
*/
|
|
12
|
+
export interface CommandContext {
|
|
13
|
+
repository: Repository;
|
|
14
|
+
/**
|
|
15
|
+
* The package whose directory rman was invoked from, or `undefined` at the repository root (and
|
|
16
|
+
* in a single-package repository, which is always "at the root") - the same
|
|
17
|
+
* `Repository.currentPackage` the built-in commands scope themselves by. A command that only
|
|
18
|
+
* makes sense inside a package should say so itself rather than assume.
|
|
19
|
+
*/
|
|
20
|
+
package: Package | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Runs one of the repository's locally installed binaries - `runBin` (see
|
|
23
|
+
* `../utils/run-bin.ts`), already carrying **this run's** settings: `cwd` defaults to the
|
|
24
|
+
* repository root, and `logLevel` to the level resolved from `--log-level` and `.rmanrc
|
|
25
|
+
* logLevel`. Either can still be overridden per call.
|
|
26
|
+
*
|
|
27
|
+
* Handed over here rather than left to be imported, because those settings are the whole point:
|
|
28
|
+
* importing `runBin` straight from `'rman'` gets a helper that knows neither, so
|
|
29
|
+
* `--log-level silent` would quietly not apply to the one part of the command that produces
|
|
30
|
+
* output. Anything else a run turns out to carry is added here the same way, and no command
|
|
31
|
+
* written against this breaks.
|
|
32
|
+
*/
|
|
33
|
+
runBin: (bin: string, argv: string[], options?: RunBinOptions) => Promise<RunBinResult>;
|
|
34
|
+
/** Logger at this run's resolved level, for a command's own narration. */
|
|
35
|
+
logger: Logger;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Which `.rmanrc` keys a command reads, for `--config` to print instead of running it - a dotted
|
|
39
|
+
* path each (`'run.build'`, `'publish.docker'`), or a function of the parsed argv when the answer
|
|
40
|
+
* depends on it (`run <script>` reads `run.<script>`).
|
|
41
|
+
*
|
|
42
|
+
* **Declared beside the command rather than in a list somewhere central**, so it cannot drift out
|
|
43
|
+
* of step with the code that does the reading, and so a plugin's command or a `.rman/*.mjs` one can
|
|
44
|
+
* say it too. Omitted, `--config` prints the whole effective config - the honest answer when
|
|
45
|
+
* nothing has said which part matters.
|
|
46
|
+
*/
|
|
47
|
+
export type ConfigKeys = string[] | ((args: ArgumentsCamelCase) => string[]);
|
|
48
|
+
export interface CustomCommand {
|
|
49
|
+
/** yargs command string, for a command taking positionals (`'deploy <stage>'`). Defaults to the
|
|
50
|
+
* module's own file name, which is the whole point of the directory. */
|
|
51
|
+
command?: string;
|
|
52
|
+
/** Required: without it `rman --help` has nothing to list the command by. */
|
|
53
|
+
describe: string;
|
|
54
|
+
builder?: (argv: Argv) => Argv;
|
|
55
|
+
handler: (context: CommandContext, args: ArgumentsCamelCase) => void | Promise<void>;
|
|
56
|
+
/** See `ConfigKeys` - what `rman <this command> --config` narrows its output to. */
|
|
57
|
+
configKeys?: ConfigKeys;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The same field on **yargs's own** command object, which is what the built-in commands pass.
|
|
61
|
+
*
|
|
62
|
+
* By augmentation rather than a wrapper type of ours: `program.command({ ... })` takes a literal,
|
|
63
|
+
* and TypeScript's excess-property check fires on a literal however the parameter is typed - so a
|
|
64
|
+
* field yargs does not know about is a compile error even though it is ignored at runtime
|
|
65
|
+
* (measured, on nine commands at once). One block, here, beside `ConfigKeys` itself.
|
|
66
|
+
*/
|
|
67
|
+
declare module 'yargs' {
|
|
68
|
+
interface CommandModule<T = {}, U = {}> {
|
|
69
|
+
configKeys?: ConfigKeys;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Identity helper for authoring a `.rman/<name>.mjs` command with full type-checking and
|
|
74
|
+
* autocomplete - the same `defineConfig` pattern, for the same reason. Returns `command`
|
|
75
|
+
* unchanged.
|
|
76
|
+
*
|
|
77
|
+
* ```js
|
|
78
|
+
* // .rman/deploy.mjs
|
|
79
|
+
* import { defineCommand, VersionService } from 'rman';
|
|
80
|
+
*
|
|
81
|
+
* export default defineCommand({
|
|
82
|
+
* describe: 'Ships what was just published to the staging cluster',
|
|
83
|
+
* builder: y => y.option('stage', { choices: ['dev', 'prod'], demandOption: true }),
|
|
84
|
+
* async handler({ repository, runBin, logger }, args) {
|
|
85
|
+
* const plan = await VersionService.getPlan(repository);
|
|
86
|
+
* for (const entry of plan.filter(e => e.status === 'bump')) {
|
|
87
|
+
* logger.info(`${entry.package.name} -> ${args.stage}`);
|
|
88
|
+
* }
|
|
89
|
+
* await runBin('helm', ['upgrade', '--install', args.stage, './chart']);
|
|
90
|
+
* },
|
|
91
|
+
* });
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* Take `runBin` from the context rather than importing it: the one on the context already carries
|
|
95
|
+
* this run's `cwd` (the repository root) and log level.
|
|
96
|
+
*
|
|
97
|
+
* This is for **one repository-level operation with logic of its own** - branching, its own CLI
|
|
98
|
+
* options, rman's services. Running a shell step across every package is what `.rmanrc
|
|
99
|
+
* "run.<script>"` already does, with the scheduling, topological order, `bail` and progress panel
|
|
100
|
+
* that come with it; reimplementing that loop here would only lose them.
|
|
101
|
+
*/
|
|
102
|
+
export declare function defineCommand(command: CustomCommand): CustomCommand;
|
|
103
|
+
export interface LoadedCommand extends CustomCommand {
|
|
104
|
+
/** The command's name - its file's basename, or the first word of an explicit `command`. */
|
|
105
|
+
name: string;
|
|
106
|
+
file: string;
|
|
107
|
+
}
|
|
108
|
+
/** A module that couldn't be loaded or doesn't look like a command. Reported, never thrown: one
|
|
109
|
+
* unparseable file must not take `rman publish` down with it. */
|
|
110
|
+
export interface CommandLoadError {
|
|
111
|
+
file: string;
|
|
112
|
+
reason: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Loads every command module in `<root>/.rman`. A repository without that directory pays nothing -
|
|
116
|
+
* no scan, no imports - which matters because this runs on *every* rman invocation, `info`
|
|
117
|
+
* included.
|
|
118
|
+
*
|
|
119
|
+
* Each failure is collected rather than thrown, so the rest of the CLI keeps working; the caller
|
|
120
|
+
* warns about them. What is *not* tolerated is a module that would shadow a built-in - see
|
|
121
|
+
* `assertNoBuiltinShadowing`.
|
|
122
|
+
*/
|
|
123
|
+
export declare function loadCustomCommands(rootDir: string): Promise<{
|
|
124
|
+
commands: LoadedCommand[];
|
|
125
|
+
errors: CommandLoadError[];
|
|
126
|
+
}>;
|
|
127
|
+
/**
|
|
128
|
+
* Refuses a command that would take a built-in's name. Unlike a module that simply fails to load,
|
|
129
|
+
* this one is thrown: the file is fine, the *name* is the mistake, and there is no reading of
|
|
130
|
+
* `rman publish` that is safe to guess at. Silently preferring either one would leave whoever typed
|
|
131
|
+
* it unable to tell which ran.
|
|
132
|
+
*/
|
|
133
|
+
export declare function assertNoBuiltinShadowing(commands: LoadedCommand[], builtins: readonly string[]): void;
|