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.
Files changed (98) hide show
  1. package/README.md +116 -85
  2. package/cli.js +225 -14
  3. package/commands/build.command.js +1 -0
  4. package/commands/changed.command.js +2 -2
  5. package/commands/changelog.command.js +13 -15
  6. package/commands/config.command.js +60 -0
  7. package/commands/diff.command.js +9 -4
  8. package/commands/exec.command.js +2 -8
  9. package/commands/github-release.command.js +1 -0
  10. package/commands/info.command.d.ts +9 -0
  11. package/commands/info.command.js +12 -2
  12. package/commands/run.command.js +5 -8
  13. package/commands/test.command.js +1 -0
  14. package/commands/version.command.js +53 -14
  15. package/constants.js +1 -1
  16. package/core/config.d.ts +257 -6
  17. package/core/config.js +409 -17
  18. package/core/custom-command.d.ts +133 -0
  19. package/core/custom-command.js +99 -0
  20. package/core/extends-config.d.ts +27 -0
  21. package/core/extends-config.js +89 -0
  22. package/core/manifest.d.ts +222 -0
  23. package/core/manifest.js +150 -0
  24. package/core/merge-config.d.ts +57 -0
  25. package/core/merge-config.js +146 -0
  26. package/core/package.d.ts +73 -7
  27. package/core/package.js +86 -24
  28. package/core/plugin.d.ts +112 -0
  29. package/core/plugin.js +189 -0
  30. package/core/repository.d.ts +85 -4
  31. package/core/repository.js +258 -81
  32. package/core/resolve-target.d.ts +12 -0
  33. package/core/resolve-target.js +33 -0
  34. package/core/version-scheme.d.ts +134 -0
  35. package/core/version-scheme.js +148 -0
  36. package/core/workspace.d.ts +68 -0
  37. package/core/workspace.js +83 -0
  38. package/index.d.ts +54 -8
  39. package/index.js +42 -7
  40. package/interfaces/rman-config.interface.d.ts +226 -28
  41. package/package.json +15 -7
  42. package/services/change-hash.service.d.ts +88 -0
  43. package/services/change-hash.service.js +112 -0
  44. package/services/changelog.service.d.ts +8 -13
  45. package/services/changelog.service.js +12 -11
  46. package/services/conventional-commits.service.d.ts +73 -0
  47. package/services/conventional-commits.service.js +116 -0
  48. package/services/docker-publish.service.js +1 -1
  49. package/services/exec.service.js +1 -1
  50. package/services/github-release.service.d.ts +2 -2
  51. package/services/github-release.service.js +10 -5
  52. package/services/list.service.js +5 -2
  53. package/services/run.service.d.ts +68 -3
  54. package/services/run.service.js +162 -70
  55. package/services/system-info.d.ts +22 -7
  56. package/services/system-info.js +8 -23
  57. package/services/version-plan.service.d.ts +244 -0
  58. package/services/version-plan.service.js +414 -0
  59. package/services/version.service.d.ts +92 -82
  60. package/services/version.service.js +233 -383
  61. package/services.d.ts +5 -3
  62. package/services.js +5 -3
  63. package/utils/bin-path.d.ts +59 -0
  64. package/utils/bin-path.js +82 -0
  65. package/utils/child-tracker.d.ts +16 -0
  66. package/utils/child-tracker.js +30 -0
  67. package/utils/exec.d.ts +13 -2
  68. package/utils/exec.js +17 -17
  69. package/utils/git.d.ts +9 -3
  70. package/utils/git.js +10 -2
  71. package/utils/package-filter.d.ts +33 -2
  72. package/utils/package-filter.js +47 -7
  73. package/utils/release-version.js +3 -3
  74. package/utils/run-bin.d.ts +46 -0
  75. package/utils/run-bin.js +63 -0
  76. package/utils/version-stamp.d.ts +48 -0
  77. package/utils/version-stamp.js +88 -0
  78. package/commands/ci.command.js +0 -30
  79. package/commands/clean.command.d.ts +0 -3
  80. package/commands/clean.command.js +0 -36
  81. package/commands/publish.command.d.ts +0 -3
  82. package/commands/publish.command.js +0 -225
  83. package/rmanrc.schema.json +0 -375
  84. package/services/ci.service.d.ts +0 -40
  85. package/services/ci.service.js +0 -204
  86. package/services/clean.service.d.ts +0 -42
  87. package/services/clean.service.js +0 -226
  88. package/services/publish.service.d.ts +0 -79
  89. package/services/publish.service.js +0 -207
  90. package/utils/change-hash.d.ts +0 -68
  91. package/utils/change-hash.js +0 -98
  92. package/utils/conventional-commits.d.ts +0 -52
  93. package/utils/conventional-commits.js +0 -90
  94. package/utils/npm-run-path.d.ts +0 -67
  95. package/utils/npm-run-path.js +0 -63
  96. package/utils/workspace-range.d.ts +0 -17
  97. package/utils/workspace-range.js +0 -28
  98. /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 merge from 'putil-merge';
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
- merge(result, pkgJson.rman, { deep: true });
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
- merge(result, obj, { deep: true });
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
- merge(result, obj, { deep: true });
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
- merge(result, obj, { deep: true });
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
- return result;
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` by cascading from `rootDir`
89
- * down to `targetDir` (inclusive), the same way tsconfig's `extends` chain
90
- * works: each directory level overrides the ones above it. This lets a
91
- * package (or any intermediate directory) narrow or override the repository's
92
- * root configuration for itself and everything below it.
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
- merge(result, local, { deep: true });
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;