rman 1.0.10 → 1.0.12

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 CHANGED
@@ -327,29 +327,40 @@ precedence: `package.json`'s own `"rman"` key, `.rmanrc.yml` (YAML), `.rmanrc` (
327
327
  the dotfile-style name), and `.rmanrc.cjs`/`.rmanrc.mjs`/`.rmanrc.js` for config that needs real
328
328
  logic (a JS module's default export).
329
329
 
330
+ **Who a declaration is about** follows one rule: unmarked keys configure the package of the
331
+ directory declaring them, and a `"[selector]"` block configures the packages it names. So the
332
+ repository root's own keys are the *root package's* - which is where repo-wide settings are read
333
+ from anyway - and they reach the other packages only through a selector.
334
+
330
335
  ```yaml
331
336
  # .rmanrc.yml, at the repository root
332
337
  packageManager: pnpm
333
338
  logLevel: info
334
339
  allowBranch: [main, release/*]
335
340
 
336
- group: true # implicit repo-wide version group by default
337
-
338
341
  version:
339
342
  commitMessage: 'chore(release): v{version}'
340
343
 
341
- changelog:
342
- ignoreTypes: [chore, ci]
343
- tagPattern: 'v*'
344
-
345
- run:
346
- build:
347
- concurrency: 4
348
- lint:
349
- topo: false
350
- bail: false
351
- test:
352
- changedSince: v1.0.0
344
+ '[*]': # every package in the repository - quotes are required in YAML
345
+ group: true # implicit repo-wide version group by default
346
+ changelog:
347
+ ignoreTypes: [chore, ci]
348
+ tagPattern: 'v*'
349
+ clean:
350
+ include: [build, '../../coverage/${{ pkg.basename }}'] # any string may embed a JS expression
351
+ run:
352
+ test: mocha # a bare string is shorthand for { exec: mocha }
353
+ build:
354
+ concurrency: 4
355
+ before: [rman run lint]
356
+ exec: tsc -b tsconfig-build.json
357
+ after: node ../../support/postbuild.cjs
358
+ lint:
359
+ topo: false
360
+ bail: false
361
+
362
+ '[*-dialect]': # a glob over package names, anchored at both ends
363
+ group: dialects
353
364
  ```
354
365
 
355
366
  ```json
@@ -363,7 +374,7 @@ run:
363
374
  ```
364
375
 
365
376
  See [docs/api.md#configuration-rmanrc-rmanrcyml](docs/api.md#configuration-rmanrc-rmanrcyml) for the
366
- full key reference (every `run.<script>.*` sub-key, `clean.*`, `changelog.*`, precedence rules,
377
+ full key reference (every `run.<script>.*` sub-key, `clean.*`, `changelog.*`, selector precedence,
367
378
  and which keys are root-level-only today).
368
379
 
369
380
  **Editor autocomplete:** `rman` ships a JSON Schema for `.rmanrc`/`.rmanrc.yml` at
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '1.0.10';
1
+ export const version = '1.0.12';
package/core/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import semver from 'semver';
1
2
  import type { RmanConfig } from '../interfaces/rman-config.interface.js';
2
3
  /**
3
4
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
@@ -21,10 +22,131 @@ export declare function defineConfig(config: RmanConfig): RmanConfig;
21
22
  */
22
23
  export declare function readDirConfig(dirname: string): Promise<RmanConfig>;
23
24
  /**
24
- * Resolves the effective config for `targetDir` by cascading from `rootDir`
25
- * down to `targetDir` (inclusive), the same way tsconfig's `extends` chain
26
- * works: each directory level overrides the ones above it. This lets a
27
- * package (or any intermediate directory) narrow or override the repository's
28
- * root configuration for itself and everything below it.
25
+ * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
26
+ * it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
27
+ * chain does.
28
+ *
29
+ * Every level contributes in two ways, and the difference is the whole model:
30
+ *
31
+ * - **Unmarked keys configure the package of the directory that declares them.** The root's own
32
+ * `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
33
+ * (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
34
+ * not, silently, every package under it.
35
+ * - **A `"[selector]"` block configures the packages it names** (`"[*]"` for all of them,
36
+ * `"[*-dialect]"` for a glob over package names). This is the only way a directory speaks about
37
+ * anything but its own package.
38
+ *
39
+ * Splitting the two matters because the same key means different things to the two audiences. The
40
+ * clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
41
+ * its own directory; on the root it's a repo-wide bookend run once at the repository root. A
42
+ * cascade that fed one declaration to both ran a package-relative command (`node
43
+ * ../../support/postbuild.cjs`) at the root, where it cannot resolve.
44
+ *
45
+ * `packageName` is what selectors match against; without it (resolving the root's own config, say)
46
+ * selector blocks contribute nothing at all.
47
+ */
48
+ export declare function resolveConfig(rootDir: string, targetDir: string, cache?: Map<string, RmanConfig>, packageName?: string): Promise<RmanConfig>;
49
+ /** A config key naming packages rather than settings: `"[*]"`, `"[*-dialect]"`, `"[pkg-a]"`. The
50
+ * brackets are what keep this space from colliding with real config keys - no setting starts with
51
+ * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
52
+ * parses as a flow sequence. */
53
+ export declare function isSelectorKey(key: string): boolean;
54
+ /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
55
+ * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
56
+ * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
57
+ export declare function selectorToRegExp(key: string): RegExp;
58
+ /** One package, as an expression sees it - the same shape for the package the config belongs to
59
+ * and for the repository itself, so `${{ repository.basename }}` reads the way `${{ pkg.basename }}` does. */
60
+ export interface PackageScope {
61
+ /** The package's own name, scope included (`@sqb/builder`). */
62
+ name: string;
63
+ /** Just the scope (`@sqb`), or `undefined` for an unscoped package. */
64
+ scope: string | undefined;
65
+ /** The name with its scope stripped (`builder`). */
66
+ unscopedName: string;
67
+ version: string;
68
+ /** The package directory's last segment (`builder`) - not always the same as `unscopedName`,
69
+ * which is why both exist, and usually what a sibling path (`../../coverage/builder`) is keyed on. */
70
+ basename: string;
71
+ /** Absolute path to the package's own directory - named as rman's own `Package.dirname` is. */
72
+ dirname: string;
73
+ /** That directory relative to the repository root (`packages/builder`), which is what a command
74
+ * addressing another package from the root usually needs. Empty string for the root itself. */
75
+ relativeDir: string;
76
+ /** The whole `package.json`, as a copy - so an expression can reach a field rman itself has no
77
+ * opinion about (`pkg.json.engines.node`). */
78
+ json: Record<string, unknown>;
79
+ }
80
+ /** Facts about the repository, on top of the root package's own - because the repository root *is*
81
+ * a package (`repository.name` is what its `package.json` says, `repository.basename` the directory
82
+ * it sits in, and the two genuinely differ). Sharing `PackageScope`'s shape is what makes
83
+ * `repository.version` read the way `pkg.version` does. */
84
+ export interface RepositoryScope extends PackageScope {
85
+ monorepo: boolean;
86
+ /** Every package in the repository - the root included only when it *is* the one package. */
87
+ packages: PackageScope[];
88
+ /** One package by name, or `undefined` - for reaching a sibling's directory. */
89
+ package(name: string): PackageScope | undefined;
90
+ /** Read from git only if an expression actually asks for it, then remembered: a repository that
91
+ * never mentions these pays nothing, and every command resolves config. All `undefined` outside
92
+ * a git checkout, which is a legitimate state rather than an error. */
93
+ git: GitScope;
94
+ }
95
+ export interface GitScope {
96
+ branch: string | undefined;
97
+ sha: string | undefined;
98
+ shortSha: string | undefined;
99
+ /** Whether the working tree has uncommitted changes. */
100
+ dirty: boolean | undefined;
101
+ }
102
+ /** What a `${{ ... }}` expression can see - the bindings of the fresh global it is evaluated in.
103
+ * Namespaced rather than a flat bag of loose names: one obvious place per fact, and room to add
104
+ * helpers to `pkg`/`repository` later without crowding the global. */
105
+ export interface ConfigScope {
106
+ /** The package the config was resolved for - which is what lets one declaration at the root
107
+ * still say something package-specific. */
108
+ pkg: PackageScope;
109
+ repository: RepositoryScope;
110
+ env: Record<string, string | undefined>;
111
+ /** rman's own `semver`, for the arithmetic every release config eventually wants
112
+ * (`semver.major(pkg.version)`). */
113
+ semver: typeof semver;
114
+ }
115
+ /**
116
+ * Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
117
+ * the package it was resolved for:
118
+ *
119
+ * ```yaml
120
+ * "[*]":
121
+ * clean:
122
+ * include: ["build", "../../coverage/${{ pkg.basename }}"]
123
+ * publish:
124
+ * docker:
125
+ * image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
126
+ * ```
127
+ *
128
+ * Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
129
+ * nobody remembers.
130
+ *
131
+ * The contents are **real JavaScript**, not a template mini-language, so there is no growing list
132
+ * of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
133
+ * in scope.
134
+ *
135
+ * **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
136
+ * something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
137
+ * rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
138
+ * Actions does: `${{ '${{' }}`.
139
+ *
140
+ * A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
141
+ * -> a boolean), since otherwise this could only ever produce strings and settings like
142
+ * `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
143
+ *
144
+ * Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
145
+ * scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
146
+ * called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
147
+ * the expression evaluator adds no trust boundary that wasn't already wide open.
148
+ *
149
+ * A failing expression throws with the config path that holds it, rather than being left in place:
150
+ * silently passing through a mistake is how a config ends up quietly doing nothing.
29
151
  */
30
- export declare function resolveConfig(rootDir: string, targetDir: string, cache?: Map<string, RmanConfig>): Promise<RmanConfig>;
152
+ export declare function interpolateConfig<T>(config: T, scope: ConfigScope): T;
package/core/config.js CHANGED
@@ -3,7 +3,9 @@ import * as yaml from 'js-yaml';
3
3
  import { createRequire } from 'module';
4
4
  import path from 'path';
5
5
  import merge from 'putil-merge';
6
+ import semver from 'semver';
6
7
  import { pathToFileURL } from 'url';
8
+ import vm from 'vm';
7
9
  /**
8
10
  * Identity helper for authoring a `.rmanrc.cjs`/`.mjs`/`.js` config with full type-checking and
9
11
  * autocomplete - the same `defineConfig` pattern Vite/Vitest use. Returns `config` completely
@@ -85,24 +87,93 @@ export async function readDirConfig(dirname) {
85
87
  return result;
86
88
  }
87
89
  /**
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.
90
+ * Resolves the effective config for the package at `targetDir`, cascading from `rootDir` down to
91
+ * it (inclusive) - each directory level overrides the ones above it, the way tsconfig's `extends`
92
+ * chain does.
93
+ *
94
+ * Every level contributes in two ways, and the difference is the whole model:
95
+ *
96
+ * - **Unmarked keys configure the package of the directory that declares them.** The root's own
97
+ * `.rmanrc` therefore configures the *root package* - which is where repo-wide settings
98
+ * (`packageManager`, `allowBranch`, `version.*`, `githubRelease.*`) are read from anyway - and
99
+ * not, silently, every package under it.
100
+ * - **A `"[selector]"` block configures the packages it names** (`"[*]"` for all of them,
101
+ * `"[*-dialect]"` for a glob over package names). This is the only way a directory speaks about
102
+ * anything but its own package.
103
+ *
104
+ * Splitting the two matters because the same key means different things to the two audiences. The
105
+ * clearest case is `run.<script>.postScript`: on a package it's that package's build hook, run in
106
+ * its own directory; on the root it's a repo-wide bookend run once at the repository root. A
107
+ * cascade that fed one declaration to both ran a package-relative command (`node
108
+ * ../../support/postbuild.cjs`) at the root, where it cannot resolve.
109
+ *
110
+ * `packageName` is what selectors match against; without it (resolving the root's own config, say)
111
+ * selector blocks contribute nothing at all.
93
112
  */
94
- export async function resolveConfig(rootDir, targetDir, cache = new Map()) {
113
+ export async function resolveConfig(rootDir, targetDir, cache = new Map(), packageName) {
95
114
  const result = {};
115
+ const target = path.resolve(targetDir);
96
116
  for (const dir of dirChain(rootDir, targetDir)) {
97
117
  let local = cache.get(dir);
98
118
  if (!local) {
99
119
  local = await readDirConfig(dir);
100
120
  cache.set(dir, local);
101
121
  }
102
- merge(result, local, { deep: true });
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
+ // A directory holding a package speaks for that package only - which is what keeps the root's
129
+ // own config off every package under it. A directory that holds none (an intermediate
130
+ // `packages/`, say) has no package to speak for, so its unmarked config can only mean
131
+ // "everything below" and still cascades.
132
+ const ownsAPackage = fs.existsSync(path.join(dir, 'package.json'));
133
+ if (!ownsAPackage || path.resolve(dir) === target)
134
+ merge(result, stripSelectors(local), { deep: true });
135
+ }
136
+ return result;
137
+ }
138
+ /** A config key naming packages rather than settings: `"[*]"`, `"[*-dialect]"`, `"[pkg-a]"`. The
139
+ * brackets are what keep this space from colliding with real config keys - no setting starts with
140
+ * one - and in YAML they also mean the key always needs quoting (`"[*]":`), since a bare `[*]`
141
+ * parses as a flow sequence. */
142
+ export function isSelectorKey(key) {
143
+ return key.length > 2 && key.startsWith('[') && key.endsWith(']');
144
+ }
145
+ /** The glob inside a selector key, as a `RegExp` anchored at both ends - so `"[*-dialect]"` matches
146
+ * `mysql-dialect` but not `my-dialect-helper`. Glob rather than regex, to match every other
147
+ * pattern in rman (`allowBranch`, `changelog.tagPattern`, `clean.include`). */
148
+ export function selectorToRegExp(key) {
149
+ const glob = key.slice(1, -1);
150
+ const source = glob
151
+ .split('*')
152
+ .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
153
+ .join('.*');
154
+ return new RegExp(`^${source}$`);
155
+ }
156
+ /** Every selector block in `config` matching `packageName`, in increasing precedence: `"[*]"` first
157
+ * and the rest in declaration order - so a specific glob overrides the catch-all, and two equally
158
+ * specific ones resolve by the order they were written in. */
159
+ function matchingSelectors(config, packageName) {
160
+ const matches = [];
161
+ for (const [key, value] of Object.entries(config)) {
162
+ if (!isSelectorKey(key) || !value || typeof value !== 'object')
163
+ continue;
164
+ if (selectorToRegExp(key).test(packageName))
165
+ matches.push([key, value]);
103
166
  }
167
+ return matches.sort((a, b) => Number(b[0] === CATCH_ALL) - Number(a[0] === CATCH_ALL)).map(([, block]) => block);
168
+ }
169
+ function stripSelectors(config) {
170
+ const result = {};
171
+ for (const [key, value] of Object.entries(config))
172
+ if (!isSelectorKey(key))
173
+ result[key] = value;
104
174
  return result;
105
175
  }
176
+ const CATCH_ALL = '[*]';
106
177
  function dirChain(rootDir, targetDir) {
107
178
  const rel = path.relative(rootDir, targetDir);
108
179
  if (!rel || rel === '.' || rel.startsWith('..'))
@@ -115,3 +186,101 @@ function dirChain(rootDir, targetDir) {
115
186
  }
116
187
  return dirs;
117
188
  }
189
+ /**
190
+ * Evaluates every `${{ ... }}` expression in **every** string value of a resolved config, against
191
+ * the package it was resolved for:
192
+ *
193
+ * ```yaml
194
+ * "[*]":
195
+ * clean:
196
+ * include: ["build", "../../coverage/${{ pkg.basename }}"]
197
+ * publish:
198
+ * docker:
199
+ * image: "panates/${{ pkg.basename }}:${{ semver.major(pkg.version) }}"
200
+ * ```
201
+ *
202
+ * Every string, with no list of "interpolated keys" to memorize - a rule with exceptions is a rule
203
+ * nobody remembers.
204
+ *
205
+ * The contents are **real JavaScript**, not a template mini-language, so there is no growing list
206
+ * of substitutions to keep adding (`{{major}}`, `{{scope}}`, ...) - see `ConfigScope` for what is
207
+ * in scope.
208
+ *
209
+ * **`${{ }}`, deliberately not `{{ }}`.** A config value may legitimately carry `{{...}}` meant for
210
+ * something else entirely (`helm template --set tag={{.Values.tag}}`); with the plainer delimiter
211
+ * rman would try to evaluate it. To emit a literal, let an expression produce it, the way GitHub
212
+ * Actions does: `${{ '${{' }}`.
213
+ *
214
+ * A string that is *nothing but* one expression keeps the value's own type (`"${{ pkg.private }}"`
215
+ * -> a boolean), since otherwise this could only ever produce strings and settings like
216
+ * `run.<script>.skip` would be unreachable. Embedded in surrounding text it is stringified.
217
+ *
218
+ * Evaluation happens in a fresh V8 context holding only the scope's bindings. That is a clean
219
+ * scope, **not a sandbox** - `node:vm` is explicitly not a security mechanism, and no sandbox is
220
+ * called for here anyway: a `.rmanrc` that can say `exec: "..."` already runs arbitrary shell, so
221
+ * the expression evaluator adds no trust boundary that wasn't already wide open.
222
+ *
223
+ * A failing expression throws with the config path that holds it, rather than being left in place:
224
+ * silently passing through a mistake is how a config ends up quietly doing nothing.
225
+ */
226
+ export function interpolateConfig(config, scope) {
227
+ const context = vm.createContext({ ...scope });
228
+ return walk(config, scope, context, []);
229
+ }
230
+ const EXPRESSION = /\$\{\{([\s\S]*?)\}\}/g;
231
+ function walk(value, scope, context, at) {
232
+ if (typeof value === 'string')
233
+ return interpolateString(value, context, at);
234
+ if (Array.isArray(value))
235
+ return value.map((item, i) => walk(item, scope, context, [...at, i]));
236
+ if (value && typeof value === 'object') {
237
+ const result = {};
238
+ for (const [key, item] of Object.entries(value))
239
+ result[key] = walk(item, scope, context, [...at, key]);
240
+ return result;
241
+ }
242
+ return value;
243
+ }
244
+ function interpolateString(value, context, at) {
245
+ if (!value.includes('${{'))
246
+ return value;
247
+ const found = [...value.matchAll(EXPRESSION)];
248
+ if (!found.length)
249
+ return value;
250
+ /** Counted rather than matched with an anchored `^...$` regex: a lazy quantifier still backtracks
251
+ * to satisfy an end anchor, so `"${{ a }} and ${{ b }}"` looked like *one* expression whose body
252
+ * ran from `a` to `b`, brace-ends and all - invalid JavaScript. */
253
+ const soleExpression = found.length === 1 && found[0][0] === value.trim();
254
+ // Alone, a nullish result is just "this setting is unset" - a legitimate answer.
255
+ if (soleExpression)
256
+ return evaluate(found[0][1], value, context, at);
257
+ return value.replace(EXPRESSION, (_, expr) => {
258
+ const result = evaluate(expr, value, context, at);
259
+ /** Embedded in text, though, it never is: splicing in the word "undefined" produces a path or
260
+ * tag like `app:undefined` that looks plausible and is wrong - the exact silent-mistake shape
261
+ * this evaluator exists to avoid. `?? 'fallback'` says what was meant. */
262
+ if (result === undefined || result === null) {
263
+ const where = at.length ? formatPath(at) : 'the config root';
264
+ throw new Error(`Expression in "${where}" is ${result} inside a string: ${value.trim()}\n` +
265
+ ` \${{${expr}}} has no value here - give it a fallback (\${{${expr.trim()} ?? '...'}}).`);
266
+ }
267
+ return String(result);
268
+ });
269
+ }
270
+ /** Names the config path as well as the expression: an error saying only "x is not defined" sends
271
+ * the reader hunting through a file that may hold dozens of them. */
272
+ function evaluate(expr, source, context, at) {
273
+ try {
274
+ return vm.runInContext(expr, context, { timeout: EXPRESSION_TIMEOUT });
275
+ }
276
+ catch (e) {
277
+ const where = at.length ? formatPath(at) : 'the config root';
278
+ throw new Error(`Invalid expression in "${where}": ${source.trim()}\n ${e?.message ?? e}`, { cause: e });
279
+ }
280
+ }
281
+ function formatPath(at) {
282
+ return at.reduce((acc, part) => (typeof part === 'number' ? `${acc}[${part}]` : acc ? `${acc}.${part}` : String(part)), '');
283
+ }
284
+ /** Guards against an expression that never returns (`while(true)`) taking the whole command with
285
+ * it - a typo, not an attack, but the failure mode is identical. */
286
+ const EXPRESSION_TIMEOUT = 1000;
@@ -37,9 +37,14 @@ export declare class Repository extends Package {
37
37
  hash?: string;
38
38
  }): Promise<Record<string, Repository.PackageStatus>>;
39
39
  /**
40
- * Resolves the effective rman config for the repository root and every
41
- * package, cascading root -> intermediate directories -> package directory,
42
- * so a `.rmanrc` placed anywhere along that path overrides the levels above it.
40
+ * Resolves the effective rman config for the repository root and every package, cascading
41
+ * root -> intermediate directories -> package directory, so a `.rmanrc` placed anywhere along
42
+ * that path overrides the levels above it.
43
+ *
44
+ * Each package is resolved *by name* as well as by directory, since that is what a `"[selector]"`
45
+ * block matches against - see `resolveConfig`. The root package is resolved by name too: in a
46
+ * single-package repository it *is* the one package, so `"[*]"` has to reach it; in a monorepo
47
+ * nothing under `getPackages()` is the root, so only its own unmarked config applies.
43
48
  */
44
49
  protected _resolveConfigs(): Promise<void>;
45
50
  protected _updateDependencies(): void;
@@ -1,8 +1,10 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import glob from 'fast-glob';
2
3
  import fs from 'fs';
3
4
  import path from 'path';
5
+ import semver from 'semver';
4
6
  import { GitHelper } from '../utils/git.js';
5
- import { resolveConfig } from './config.js';
7
+ import { interpolateConfig, resolveConfig } from './config.js';
6
8
  import { Package } from './package.js';
7
9
  export class Repository extends Package {
8
10
  dirname;
@@ -87,20 +89,55 @@ export class Repository extends Package {
87
89
  return result;
88
90
  }
89
91
  /**
90
- * Resolves the effective rman config for the repository root and every
91
- * package, cascading root -> intermediate directories -> package directory,
92
- * so a `.rmanrc` placed anywhere along that path overrides the levels above it.
92
+ * Resolves the effective rman config for the repository root and every package, cascading
93
+ * root -> intermediate directories -> package directory, so a `.rmanrc` placed anywhere along
94
+ * that path overrides the levels above it.
95
+ *
96
+ * Each package is resolved *by name* as well as by directory, since that is what a `"[selector]"`
97
+ * block matches against - see `resolveConfig`. The root package is resolved by name too: in a
98
+ * single-package repository it *is* the one package, so `"[*]"` has to reach it; in a monorepo
99
+ * nothing under `getPackages()` is the root, so only its own unmarked config applies.
93
100
  */
94
101
  async _resolveConfigs() {
95
102
  const cache = new Map();
96
- const rootConfig = await resolveConfig(this.dirname, this.dirname, cache);
97
- this.config = rootConfig;
98
- this.rootPackage.config = rootConfig;
103
+ const repoDir = this.dirname;
104
+ const scopeOf = (pkg) => {
105
+ // A package.json without a "name" is unusual but legal, and `info` prints such a package
106
+ // rather than refusing it - so the scope has to survive one too.
107
+ const name = pkg.name ?? '';
108
+ const at = name.lastIndexOf('/');
109
+ return {
110
+ name,
111
+ scope: at > 0 ? name.slice(0, at) : undefined,
112
+ unscopedName: at > 0 ? name.slice(at + 1) : name,
113
+ version: pkg.version ?? '',
114
+ basename: path.basename(pkg.dirname),
115
+ dirname: pkg.dirname,
116
+ relativeDir: path.relative(this.dirname, pkg.dirname),
117
+ // A copy: an expression has no business mutating the package rman is about to act on.
118
+ json: { ...pkg.json },
119
+ };
120
+ };
121
+ const packageScopes = this.packages.map(scopeOf);
122
+ let gitScope;
123
+ const repository = {
124
+ ...scopeOf(this.rootPackage),
125
+ monorepo: this.monorepo,
126
+ packages: packageScopes,
127
+ package: (name) => packageScopes.find(p => p.name === name),
128
+ // A getter, so a repository whose config never mentions git spawns no git at all - and every
129
+ // command resolves config, not just the ones that care.
130
+ get git() {
131
+ return (gitScope ??= readGitScope(repoDir));
132
+ },
133
+ };
134
+ const withVars = (pkg, config) => interpolateConfig(config, { pkg: scopeOf(pkg), repository, env: { ...process.env }, semver });
135
+ this.config = withVars(this.rootPackage, await resolveConfig(this.dirname, this.dirname, cache));
99
136
  for (const pkg of this.packages) {
100
- if (pkg === this.rootPackage)
101
- continue;
102
- pkg.config = await resolveConfig(this.dirname, pkg.dirname, cache);
137
+ pkg.config = withVars(pkg, await resolveConfig(this.dirname, pkg.dirname, cache, pkg.name));
103
138
  }
139
+ if (this.monorepo)
140
+ this.rootPackage.config = this.config;
104
141
  }
105
142
  _updateDependencies() {
106
143
  const deps = {};
@@ -111,7 +148,7 @@ export class Repository extends Package {
111
148
  ...pkg.json.peerDependencies,
112
149
  ...pkg.json.optionalDependencies,
113
150
  };
114
- const configDeps = pkg.config.packages?.[pkg.name]?.dependencies;
151
+ const configDeps = pkg.config.dependencies;
115
152
  if (configDeps) {
116
153
  if (Array.isArray(configDeps))
117
154
  configDeps.forEach(x => (o[x] = o[x] || '*'));
@@ -205,3 +242,30 @@ function topoSortPackages(packages) {
205
242
  return 0;
206
243
  });
207
244
  }
245
+ /** `git` facts for a `${{ repository.git.* }}` expression. Synchronous on purpose: it backs a lazy
246
+ * getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
247
+ * error, just a repository without one. */
248
+ function readGitScope(dirname) {
249
+ const run = (args) => {
250
+ try {
251
+ return execFileSync('git', args, { cwd: dirname, stdio: ['ignore', 'pipe', 'ignore'] })
252
+ .toString()
253
+ .trim();
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ };
259
+ const sha = run(['rev-parse', 'HEAD']);
260
+ if (sha === undefined)
261
+ return { branch: undefined, sha: undefined, shortSha: undefined, dirty: undefined };
262
+ const status = run(['status', '--porcelain']);
263
+ return {
264
+ // Empty on a detached HEAD, which is what a CI checkout often is - reported as undefined
265
+ // rather than an empty string, so `?? 'detached'` in an expression works.
266
+ branch: run(['branch', '--show-current']) || undefined,
267
+ sha,
268
+ shortSha: sha.slice(0, 7),
269
+ dirty: status === undefined ? undefined : status.length > 0,
270
+ };
271
+ }