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 +26 -15
- package/constants.js +1 -1
- package/core/config.d.ts +128 -6
- package/core/config.js +176 -7
- package/core/repository.d.ts +8 -3
- package/core/repository.js +75 -11
- package/interfaces/rman-config.interface.d.ts +76 -13
- package/package.json +1 -1
- package/rmanrc.schema.json +54 -37
- package/services/publish.service.js +113 -47
- package/services/run.service.d.ts +8 -3
- package/services/run.service.js +63 -15
- package/services/version.service.js +67 -3
- package/utils/npm-run-path.d.ts +1 -1
- package/utils/version-stamp.d.ts +40 -0
- package/utils/version-stamp.js +76 -0
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
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.
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
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;
|
package/core/repository.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
42
|
-
*
|
|
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;
|
package/core/repository.js
CHANGED
|
@@ -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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|