rman 1.0.12 → 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 +90 -70
- 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 +142 -13
- package/core/config.js +266 -43
- 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 +77 -1
- package/core/repository.js +242 -129
- 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 +171 -36
- 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 +60 -0
- package/services/run.service.js +109 -65
- 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 +219 -433
- 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 +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
|
@@ -2,10 +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 merge from 'putil-merge';
|
|
6
5
|
import semver from 'semver';
|
|
7
6
|
import { pathToFileURL } from 'url';
|
|
8
7
|
import vm from 'vm';
|
|
8
|
+
import { assertNoSelectorExtends, EXTENDS_KEY, resolveExtends } from './extends-config.js';
|
|
9
|
+
import { finalizeConfig, mergeConfig } from './merge-config.js';
|
|
9
10
|
/**
|
|
10
11
|
* Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
|
|
11
12
|
* autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
|
|
@@ -58,33 +59,56 @@ async function loadJsConfig(file) {
|
|
|
58
59
|
*/
|
|
59
60
|
export async function readDirConfig(dirname) {
|
|
60
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');
|
|
61
65
|
const pkgJsonFile = path.join(dirname, 'package.json');
|
|
62
66
|
if (fs.existsSync(pkgJsonFile)) {
|
|
63
67
|
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
|
|
64
|
-
if (pkgJson && typeof pkgJson.rman === 'object')
|
|
65
|
-
|
|
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
|
+
}
|
|
66
74
|
}
|
|
67
75
|
const ymlFile = path.join(dirname, '.rmanrc.yml');
|
|
68
76
|
if (fs.existsSync(ymlFile)) {
|
|
69
77
|
const obj = yaml.load(fs.readFileSync(ymlFile, 'utf-8'));
|
|
70
|
-
if (obj && typeof obj === 'object')
|
|
71
|
-
|
|
78
|
+
if (obj && typeof obj === 'object') {
|
|
79
|
+
assertNoSelectorExtends(obj, ymlFile);
|
|
80
|
+
if (EXTENDS_KEY in obj)
|
|
81
|
+
extendsFrom = ymlFile;
|
|
82
|
+
mergeConfig(result, obj);
|
|
83
|
+
}
|
|
72
84
|
}
|
|
73
85
|
const rcFile = path.join(dirname, '.rmanrc');
|
|
74
86
|
if (fs.existsSync(rcFile)) {
|
|
75
87
|
const obj = JSON.parse(fs.readFileSync(rcFile, 'utf-8'));
|
|
76
|
-
if (obj && typeof obj === 'object')
|
|
77
|
-
|
|
88
|
+
if (obj && typeof obj === 'object') {
|
|
89
|
+
assertNoSelectorExtends(obj, rcFile);
|
|
90
|
+
if (EXTENDS_KEY in obj)
|
|
91
|
+
extendsFrom = rcFile;
|
|
92
|
+
mergeConfig(result, obj);
|
|
93
|
+
}
|
|
78
94
|
}
|
|
79
95
|
for (const jsFileName of JS_CONFIG_FILES) {
|
|
80
96
|
const jsFile = path.join(dirname, jsFileName);
|
|
81
97
|
if (fs.existsSync(jsFile)) {
|
|
82
98
|
const obj = await loadJsConfig(jsFile);
|
|
83
|
-
if (obj && typeof obj === 'object')
|
|
84
|
-
|
|
99
|
+
if (obj && typeof obj === 'object') {
|
|
100
|
+
assertNoSelectorExtends(obj, jsFile);
|
|
101
|
+
if (EXTENDS_KEY in obj)
|
|
102
|
+
extendsFrom = jsFile;
|
|
103
|
+
mergeConfig(result, obj);
|
|
104
|
+
}
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
|
-
|
|
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);
|
|
88
112
|
}
|
|
89
113
|
/**
|
|
90
114
|
* Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
|
|
@@ -97,9 +121,10 @@ export async function readDirConfig(dirname) {
|
|
|
97
121
|
* `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
|
|
98
122
|
* (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
|
|
99
123
|
* not, silently, every package under it.
|
|
100
|
-
* - **A `"[selector]"` block configures the packages it names**
|
|
101
|
-
* `"[
|
|
102
|
-
*
|
|
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.
|
|
103
128
|
*
|
|
104
129
|
* Splitting the two matters because the same key means different things to the two audiences. The
|
|
105
130
|
* clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
|
|
@@ -107,64 +132,137 @@ export async function readDirConfig(dirname) {
|
|
|
107
132
|
* cascade that fed one declaration to both ran a package-relative command (`node
|
|
108
133
|
* ../../support/postbuild.cjs`) at the root, where it cannot resolve.
|
|
109
134
|
*
|
|
110
|
-
* `packageName` is what selectors match against; without it
|
|
111
|
-
*
|
|
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.
|
|
112
137
|
*/
|
|
113
138
|
export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
|
|
114
139
|
const result = {};
|
|
115
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);
|
|
116
145
|
for (const dir of dirChain(rootDir, targetDir)) {
|
|
117
146
|
let local = cache.get(dir);
|
|
118
147
|
if (!local) {
|
|
119
148
|
local = await readDirConfig(dir);
|
|
120
149
|
cache.set(dir, local);
|
|
121
150
|
}
|
|
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
151
|
// A directory holding a package speaks for that package only - which is what keeps the root's
|
|
129
152
|
// own config off every package under it. A directory that holds none (an intermediate
|
|
130
153
|
// `packages/`, say) has no package to speak for, so its unmarked config can only mean
|
|
131
154
|
// "everything below" and still cascades.
|
|
132
155
|
const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
|
|
133
|
-
|
|
134
|
-
|
|
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));
|
|
135
177
|
}
|
|
136
|
-
|
|
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);
|
|
137
182
|
}
|
|
138
|
-
/** A config key naming packages rather than settings: `"[*]"`, `"[
|
|
183
|
+
/** A config key naming packages rather than settings: `"[*]"`, `"[/]"`, `"[ws:*]"`, `"[pkg-a]"`. The
|
|
139
184
|
* brackets are what keep this space from colliding with real config keys - no setting starts with
|
|
140
185
|
* one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
|
|
141
186
|
* parses as a flow sequence. */
|
|
142
187
|
export function isSelectorKey(key) {
|
|
143
188
|
return key.length > 2 && key.startsWith('[') && key.endsWith(']');
|
|
144
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
|
+
}
|
|
145
221
|
/** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
|
|
146
222
|
* `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
|
|
147
223
|
* pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
|
|
148
224
|
export function selectorToRegExp(key) {
|
|
149
|
-
|
|
150
|
-
const source = glob
|
|
151
|
-
.split('*')
|
|
152
|
-
.map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
153
|
-
.join('.*');
|
|
154
|
-
return new RegExp(`^${source}$`);
|
|
225
|
+
return globToRegExp(key.slice(1, -1));
|
|
155
226
|
}
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
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) {
|
|
160
237
|
const matches = [];
|
|
161
238
|
for (const [key, value] of Object.entries(config)) {
|
|
162
239
|
if (!isSelectorKey(key) || !value || typeof value !== 'object')
|
|
163
240
|
continue;
|
|
164
|
-
|
|
165
|
-
|
|
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]);
|
|
166
249
|
}
|
|
167
|
-
return matches.sort((a, b) =>
|
|
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}$`);
|
|
168
266
|
}
|
|
169
267
|
function stripSelectors(config) {
|
|
170
268
|
const result = {};
|
|
@@ -174,6 +272,12 @@ function stripSelectors(config) {
|
|
|
174
272
|
return result;
|
|
175
273
|
}
|
|
176
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:'];
|
|
177
281
|
function dirChain(rootDir, targetDir) {
|
|
178
282
|
const rel = path.relative(rootDir, targetDir);
|
|
179
283
|
if (!rel || rel === '.' || rel.startsWith('..'))
|
|
@@ -195,8 +299,13 @@ function dirChain(rootDir, targetDir) {
|
|
|
195
299
|
* clean:
|
|
196
300
|
* include: ["build", "../../coverage/${{ pkg.basename }}"]
|
|
197
301
|
* publish:
|
|
302
|
+
* directory: build
|
|
198
303
|
* docker:
|
|
199
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 }}/"
|
|
200
309
|
* ```
|
|
201
310
|
*
|
|
202
311
|
* Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
|
|
@@ -223,20 +332,134 @@ function dirChain(rootDir, targetDir) {
|
|
|
223
332
|
* A failing expression throws with the config path that holds it, rather than being left in place:
|
|
224
333
|
* silently passing through a mistake is how a config ends up quietly doing nothing.
|
|
225
334
|
*/
|
|
226
|
-
export function interpolateConfig(config, scope) {
|
|
335
|
+
export function interpolateConfig(config, scope, options) {
|
|
336
|
+
const skip = options?.skip ?? [];
|
|
227
337
|
const context = vm.createContext({ ...scope });
|
|
228
|
-
|
|
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;
|
|
229
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'];
|
|
230
412
|
const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
|
|
231
|
-
|
|
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;
|
|
232
455
|
if (typeof value === 'string')
|
|
233
456
|
return interpolateString(value, context, at);
|
|
234
457
|
if (Array.isArray(value))
|
|
235
|
-
return value.map((item, i) => walk(item, scope, context, [...at, i]));
|
|
458
|
+
return value.map((item, i) => walk(item, scope, context, [...at, i], skip));
|
|
236
459
|
if (value && typeof value === 'object') {
|
|
237
460
|
const result = {};
|
|
238
461
|
for (const [key, item] of Object.entries(value))
|
|
239
|
-
result[key] = walk(item, scope, context, [...at, key]);
|
|
462
|
+
result[key] = walk(item, scope, context, [...at, key], skip);
|
|
240
463
|
return result;
|
|
241
464
|
}
|
|
242
465
|
return value;
|
|
@@ -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;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
/** Where a repository keeps its own commands - one module per command, named after it. */
|
|
5
|
+
export const CUSTOM_COMMAND_DIR = '.rman';
|
|
6
|
+
/** Loadable module forms, matching what a `.rmanrc.cjs`/`.mjs`/`.js` config already accepts. A
|
|
7
|
+
* `.ts` command would need a loader registered in rman's own process, which is a separate
|
|
8
|
+
* question from this one. */
|
|
9
|
+
const EXTENSIONS = ['.js', '.mjs', '.cjs'];
|
|
10
|
+
/**
|
|
11
|
+
* Identity helper for authoring a `.rman/<name>.mjs` command with full type-checking and
|
|
12
|
+
* autocomplete - the same `defineConfig` pattern, for the same reason. Returns `command`
|
|
13
|
+
* unchanged.
|
|
14
|
+
*
|
|
15
|
+
* ```js
|
|
16
|
+
* // .rman/deploy.mjs
|
|
17
|
+
* import { defineCommand, VersionService } from 'rman';
|
|
18
|
+
*
|
|
19
|
+
* export default defineCommand({
|
|
20
|
+
* describe: 'Ships what was just published to the staging cluster',
|
|
21
|
+
* builder: y => y.option('stage', { choices: ['dev', 'prod'], demandOption: true }),
|
|
22
|
+
* async handler({ repository, runBin, logger }, args) {
|
|
23
|
+
* const plan = await VersionService.getPlan(repository);
|
|
24
|
+
* for (const entry of plan.filter(e => e.status === 'bump')) {
|
|
25
|
+
* logger.info(`${entry.package.name} -> ${args.stage}`);
|
|
26
|
+
* }
|
|
27
|
+
* await runBin('helm', ['upgrade', '--install', args.stage, './chart']);
|
|
28
|
+
* },
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Take `runBin` from the context rather than importing it: the one on the context already carries
|
|
33
|
+
* this run's `cwd` (the repository root) and log level.
|
|
34
|
+
*
|
|
35
|
+
* This is for **one repository-level operation with logic of its own** - branching, its own CLI
|
|
36
|
+
* options, rman's services. Running a shell step across every package is what `.rmanrc
|
|
37
|
+
* "run.<script>"` already does, with the scheduling, topological order, `bail` and progress panel
|
|
38
|
+
* that come with it; reimplementing that loop here would only lose them.
|
|
39
|
+
*/
|
|
40
|
+
export function defineCommand(command) {
|
|
41
|
+
return command;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Loads every command module in `<root>/.rman`. A repository without that directory pays nothing -
|
|
45
|
+
* no scan, no imports - which matters because this runs on *every* rman invocation, `info`
|
|
46
|
+
* included.
|
|
47
|
+
*
|
|
48
|
+
* Each failure is collected rather than thrown, so the rest of the CLI keeps working; the caller
|
|
49
|
+
* warns about them. What is *not* tolerated is a module that would shadow a built-in - see
|
|
50
|
+
* `assertNoBuiltinShadowing`.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadCustomCommands(rootDir) {
|
|
53
|
+
const dir = path.join(rootDir, CUSTOM_COMMAND_DIR);
|
|
54
|
+
const commands = [];
|
|
55
|
+
const errors = [];
|
|
56
|
+
if (!fs.existsSync(dir))
|
|
57
|
+
return { commands, errors };
|
|
58
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
59
|
+
if (!entry.isFile() || !EXTENSIONS.includes(path.extname(entry.name)))
|
|
60
|
+
continue;
|
|
61
|
+
const file = path.join(dir, entry.name);
|
|
62
|
+
try {
|
|
63
|
+
const mod = await import(pathToFileURL(file).href);
|
|
64
|
+
const command = mod?.default ?? mod?.command;
|
|
65
|
+
if (!command || typeof command !== 'object') {
|
|
66
|
+
throw new Error('no default export - end the module with `export default defineCommand({ ... })`');
|
|
67
|
+
}
|
|
68
|
+
if (typeof command.handler !== 'function')
|
|
69
|
+
throw new Error('"handler" is missing, or is not a function');
|
|
70
|
+
if (typeof command.describe !== 'string' || !command.describe) {
|
|
71
|
+
throw new Error('"describe" is missing - `rman --help` has nothing to list the command by without it');
|
|
72
|
+
}
|
|
73
|
+
const declared = command.command?.trim();
|
|
74
|
+
commands.push({
|
|
75
|
+
...command,
|
|
76
|
+
command: declared || path.basename(entry.name, path.extname(entry.name)),
|
|
77
|
+
name: (declared || path.basename(entry.name, path.extname(entry.name))).split(/\s+/)[0],
|
|
78
|
+
file,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
errors.push({ file, reason: e?.message ?? String(e) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { commands, errors };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Refuses a command that would take a built-in's name. Unlike a module that simply fails to load,
|
|
89
|
+
* this one is thrown: the file is fine, the *name* is the mistake, and there is no reading of
|
|
90
|
+
* `rman publish` that is safe to guess at. Silently preferring either one would leave whoever typed
|
|
91
|
+
* it unable to tell which ran.
|
|
92
|
+
*/
|
|
93
|
+
export function assertNoBuiltinShadowing(commands, builtins) {
|
|
94
|
+
const clash = commands.find(c => builtins.includes(c.name));
|
|
95
|
+
if (!clash)
|
|
96
|
+
return;
|
|
97
|
+
throw new Error(`"${path.relative(process.cwd(), clash.file)}" would shadow rman's built-in "${clash.name}" command.\n` +
|
|
98
|
+
` Rename the file, or give it its own name with \`command: '<name>'\`.`);
|
|
99
|
+
}
|