rman 2.0.0-beta.2 → 2.0.0-beta.4

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 (162) hide show
  1. package/README.md +2 -12
  2. package/{plugins/node/augmentation/rman.augmentation.d.ts → builtins/platforms/node/augmentation/rmanrc.augmentation.d.ts} +4 -5
  3. package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.js +1 -1
  4. package/{plugins → builtins/platforms}/node/commands/ci.command.d.ts +5 -3
  5. package/{plugins → builtins/platforms}/node/commands/ci.command.js +6 -5
  6. package/{plugins → builtins/platforms}/node/commands/clean.command.d.ts +6 -4
  7. package/{plugins → builtins/platforms}/node/commands/clean.command.js +6 -5
  8. package/builtins/platforms/node/index.d.ts +8 -0
  9. package/builtins/platforms/node/index.js +8 -0
  10. package/{plugins → builtins/platforms}/node/node-config.interface.d.ts +1 -2
  11. package/{plugins → builtins/platforms}/node/node-manifest.provider.d.ts +3 -3
  12. package/{plugins → builtins/platforms}/node/node-manifest.provider.js +3 -3
  13. package/{plugins → builtins/platforms}/node/node.platform.d.ts +3 -3
  14. package/{plugins → builtins/platforms}/node/services/ci.service.d.ts +3 -3
  15. package/{plugins → builtins/platforms}/node/services/ci.service.js +4 -4
  16. package/{plugins → builtins/platforms}/node/services/clean.service.d.ts +3 -3
  17. package/{plugins → builtins/platforms}/node/services/clean.service.js +3 -3
  18. package/{plugins → builtins/platforms}/node/services/publish.service.d.ts +4 -4
  19. package/{plugins → builtins/platforms}/node/services/publish.service.js +13 -7
  20. package/{plugins → builtins/platforms}/node/services/version-plan.service.d.ts +3 -3
  21. package/{plugins → builtins/platforms}/node/services/version-plan.service.js +2 -2
  22. package/builtins/presets/index.d.ts +15 -0
  23. package/builtins/presets/index.js +57 -0
  24. package/builtins/presets/node.d.ts +16 -0
  25. package/builtins/presets/node.js +57 -0
  26. package/{services → builtins/publish-targets/docker}/docker-publish.service.d.ts +6 -6
  27. package/{services → builtins/publish-targets/docker}/docker-publish.service.js +9 -9
  28. package/{targets → builtins/publish-targets/docker}/docker.target.d.ts +6 -5
  29. package/{targets → builtins/publish-targets/docker}/docker.target.js +1 -1
  30. package/builtins/publish-targets/docker/index.d.ts +2 -0
  31. package/builtins/publish-targets/docker/index.js +2 -0
  32. package/builtins/publish-targets/npm/index.d.ts +2 -0
  33. package/builtins/publish-targets/npm/index.js +2 -0
  34. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.d.ts +4 -4
  35. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.js +4 -5
  36. package/cli.d.ts +3 -0
  37. package/cli.js +16 -7
  38. package/commands/changelog.command.d.ts +106 -2
  39. package/commands/changelog.command.js +40 -0
  40. package/commands/config.command.js +23 -5
  41. package/commands/list.command.js +1 -1
  42. package/commands/publish.command.d.ts +1 -1
  43. package/commands/publish.command.js +1 -1
  44. package/commands/run.command.js +1 -1
  45. package/commands/version.command.d.ts +16 -3
  46. package/commands/version.command.js +143 -9
  47. package/commands.d.ts +0 -1
  48. package/commands.js +0 -1
  49. package/constants.js +1 -1
  50. package/core/application.d.ts +10 -10
  51. package/core/application.js +28 -11
  52. package/core/{package.d.ts → classes/package.d.ts} +12 -4
  53. package/core/{package.js → classes/package.js} +17 -1
  54. package/core/{repository.d.ts → classes/repository.d.ts} +22 -36
  55. package/core/{repository.js → classes/repository.js} +112 -241
  56. package/core/{service.d.ts → classes/service.d.ts} +2 -2
  57. package/core/classes/workspace.d.ts +277 -0
  58. package/core/classes/workspace.js +585 -0
  59. package/core/command-builder.js +17 -1
  60. package/core/config/config-file-scope.d.ts +107 -0
  61. package/core/config/config-file-scope.js +226 -0
  62. package/core/config/config-interpolator.d.ts +164 -0
  63. package/core/config/config-interpolator.js +577 -0
  64. package/core/config/config-paths.d.ts +65 -0
  65. package/core/config/config-paths.js +78 -0
  66. package/core/config/config-reader.d.ts +215 -0
  67. package/core/config/config-reader.js +851 -0
  68. package/core/{merge-config.js → config/merge-config.js} +4 -8
  69. package/core/{custom-command.d.ts → interfaces/custom-command.d.ts} +5 -5
  70. package/core/{manifest.d.ts → interfaces/manifest.d.ts} +3 -3
  71. package/core/{manifest.js → interfaces/manifest.js} +2 -2
  72. package/core/{plugin.d.ts → interfaces/plugin.d.ts} +44 -27
  73. package/core/{plugin.js → interfaces/plugin.js} +48 -10
  74. package/core/{publish-target.d.ts → interfaces/publish-target.d.ts} +24 -12
  75. package/core/{publish-target.js → interfaces/publish-target.js} +16 -3
  76. package/core/{run-step.d.ts → interfaces/run-step.d.ts} +4 -4
  77. package/core/plugin-loader.d.ts +11 -51
  78. package/core/plugin-loader.js +21 -202
  79. package/index.d.ts +29 -28
  80. package/index.js +20 -46
  81. package/interfaces/config-scope.interface.d.ts +223 -0
  82. package/interfaces/config-scope.interface.js +1 -0
  83. package/interfaces/rman-config.interface.d.ts +100 -5
  84. package/interfaces/rman-config.interface.js +17 -0
  85. package/package.json +3 -2
  86. package/services/change-hash.service.d.ts +40 -6
  87. package/services/change-hash.service.js +90 -11
  88. package/services/changelog.service.d.ts +31 -3
  89. package/services/changelog.service.js +448 -65
  90. package/services/config-service.d.ts +8 -0
  91. package/services/config-service.js +3 -0
  92. package/services/exec.service.d.ts +2 -2
  93. package/services/exec.service.js +1 -1
  94. package/services/github-release.service.d.ts +3 -3
  95. package/services/github-release.service.js +3 -3
  96. package/services/import.service.d.ts +2 -2
  97. package/services/import.service.js +1 -1
  98. package/services/list.service.d.ts +9 -7
  99. package/services/list.service.js +2 -2
  100. package/services/run.service.d.ts +15 -11
  101. package/services/run.service.js +62 -18
  102. package/services/system-info.d.ts +1 -1
  103. package/services/version-plan.service.d.ts +8 -4
  104. package/services/version-plan.service.js +54 -21
  105. package/services/version.service.d.ts +5 -5
  106. package/services/version.service.js +7 -4
  107. package/services.d.ts +1 -1
  108. package/services.js +1 -1
  109. package/utils/branch-guard.d.ts +1 -1
  110. package/utils/color-yaml.d.ts +8 -0
  111. package/utils/color-yaml.js +86 -0
  112. package/utils/git.d.ts +26 -0
  113. package/utils/git.js +65 -0
  114. package/utils/logger.d.ts +1 -1
  115. package/utils/package-filter.d.ts +8 -1
  116. package/utils/package-filter.js +7 -0
  117. package/utils/printable-config.d.ts +8 -0
  118. package/utils/printable-config.js +45 -0
  119. package/utils/progress-panel.d.ts +9 -0
  120. package/utils/progress-panel.js +23 -1
  121. package/utils/release-version.d.ts +1 -1
  122. package/utils/run-options.js +1 -1
  123. package/utils/version-group.d.ts +31 -0
  124. package/utils/version-group.js +43 -0
  125. package/commands/changed.command.d.ts +0 -80
  126. package/commands/changed.command.js +0 -36
  127. package/core/config.d.ts +0 -530
  128. package/core/config.js +0 -1123
  129. package/core/core-services.d.ts +0 -14
  130. package/core/core-services.js +0 -30
  131. package/core/core-targets.d.ts +0 -14
  132. package/core/core-targets.js +0 -16
  133. package/core/extends-config.d.ts +0 -35
  134. package/core/extends-config.js +0 -129
  135. package/core/load-config-module.d.ts +0 -28
  136. package/core/load-config-module.js +0 -42
  137. package/core/workspace.d.ts +0 -119
  138. package/core/workspace.js +0 -124
  139. package/plugins/builtins.d.ts +0 -44
  140. package/plugins/builtins.js +0 -33
  141. package/plugins/detect.d.ts +0 -78
  142. package/plugins/detect.js +0 -70
  143. package/plugins/node/index.d.ts +0 -29
  144. package/plugins/node/index.js +0 -40
  145. /package/{core/run-step.js → builtins/platforms/node/augmentation/rmanrc.augmentation.js} +0 -0
  146. /package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.d.ts +0 -0
  147. /package/{plugins → builtins/platforms}/node/node-config.interface.js +0 -0
  148. /package/{plugins → builtins/platforms}/node/node.platform.js +0 -0
  149. /package/{plugins → builtins/platforms}/node/utils/workspace-range.d.ts +0 -0
  150. /package/{plugins → builtins/platforms}/node/utils/workspace-range.js +0 -0
  151. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.d.ts +0 -0
  152. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.js +0 -0
  153. /package/core/{registry.d.ts → classes/registry.d.ts} +0 -0
  154. /package/core/{registry.js → classes/registry.js} +0 -0
  155. /package/core/{service.js → classes/service.js} +0 -0
  156. /package/core/{version-scheme.d.ts → classes/version-scheme.d.ts} +0 -0
  157. /package/core/{version-scheme.js → classes/version-scheme.js} +0 -0
  158. /package/core/{merge-config.d.ts → config/merge-config.d.ts} +0 -0
  159. /package/core/{resolve-target.d.ts → config/resolve-config-target.d.ts} +0 -0
  160. /package/core/{resolve-target.js → config/resolve-config-target.js} +0 -0
  161. /package/core/{custom-command.js → interfaces/custom-command.js} +0 -0
  162. /package/{plugins/node/augmentation/rman.augmentation.js → core/interfaces/run-step.js} +0 -0
@@ -0,0 +1,585 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DEFAULT_PRESETS } from '../../builtins/presets/index.js';
4
+ import { ConfigReader } from '../config/config-reader.js';
5
+ import { mergeConfig, ORIGINS } from '../config/merge-config.js';
6
+ import { basePlatform } from '../interfaces/plugin.js';
7
+ import { Package } from './package.js';
8
+ /**
9
+ * A repository's layout, resolved: which directories hold packages, which technology claims each of
10
+ * them, what each one answers to, and the **raw** config each one ends up with.
11
+ *
12
+ * Built with {@link Workspace.create}, which runs the whole resolution; the constructor is
13
+ * protected, so there is no such thing as a half-built workspace.
14
+ *
15
+ * The configs it produces are **raw** - every `${{ }}` and every value function is still
16
+ * unevaluated. Resolving one needs `pkg`, `repository` and `git`, which belong to the repository
17
+ * rather than to its layout; `ConfigInterpolator` is the step after this.
18
+ */
19
+ /* The three steps live together because they depend on each other in one direction only: the root's
20
+ * config names the plugins, the plugins decide which directories are packages, and a package's
21
+ * config is the directory chain above it layered together. None of it can be done out of order, and
22
+ * each step is useless on its own.
23
+ *
24
+ * Every step is an overridable `protected` method, so a technology or a spec can replace one and
25
+ * keep the rest. */
26
+ export class Workspace {
27
+ options;
28
+ /** The repository root - where the walk starts and what every directory chain is relative to. */
29
+ rootDir;
30
+ /** Every plugin in play, in load order: the ones passed in first, then each level's own. */
31
+ plugins = [];
32
+ /** Every technology in play, in contribution order - which is what decides who claims a
33
+ * directory, since the first that recognizes it wins. */
34
+ platforms = [];
35
+ /** The root directory's own package. Not one of `packages`, which holds the members. */
36
+ rootPackage;
37
+ /** Every package below the root, depth-first. */
38
+ packages = [];
39
+ /** Each directory's own config, read once - see `_readLevel`. */
40
+ _levels = new Map();
41
+ /** The packages whose selector came from a `"name"` declaration rather than from their platform,
42
+ * for the clash message alone. */
43
+ _declaredSelectors = new Set();
44
+ /** Built on first use, unless `options.reader` supplied one. */
45
+ _configReader;
46
+ /**
47
+ * Builds a workspace and resolves it: discovery, selectors, and every package's raw config.
48
+ *
49
+ * A subclass inherits this and gets an instance of itself. Its declared return type stays
50
+ * `Workspace`, so a subclass wanting its own declares a one-line `create` that calls this.
51
+ *
52
+ * @param rootDir the repository root
53
+ */
54
+ /* **`new this()`, not `new Workspace()`.** In a static method `this` is the class the call was
55
+ * made on, so a subclass inheriting this factory builds itself; hard-coding the name would let
56
+ * someone override `_init` and never see it run. The spec for it asserts `instanceof` on the
57
+ * subclass.
58
+ *
59
+ * **The return type is a TypeScript limit rather than a choice.** The polymorphic form
60
+ * (`this: new (...) => T`) requires a *publicly* constructible class, and the constructor here is
61
+ * protected on purpose. The two cannot both be had, and the guard is worth more than the inferred
62
+ * type. */
63
+ static async create(rootDir, options) {
64
+ const workspace = new this(rootDir, options);
65
+ await workspace._init();
66
+ return workspace;
67
+ }
68
+ /** Use {@link Workspace.create}. */
69
+ /* Protected because an un-initialized workspace is a half-built object - no packages, no configs,
70
+ * and nothing saying so. */
71
+ constructor(rootDir, options) {
72
+ this.options = options;
73
+ this.rootDir = path.resolve(rootDir);
74
+ this.plugins = [...(options.plugins ?? [])];
75
+ this.platforms = [...(options.platforms ?? [])];
76
+ }
77
+ /** The package at a directory, root included, or `undefined` for one that holds none. */
78
+ packageAt(dirname) {
79
+ const resolved = path.resolve(dirname);
80
+ if (this.rootPackage && this.rootPackage.dirname === resolved)
81
+ return this.rootPackage;
82
+ return this.packages.find(pkg => pkg.dirname === resolved);
83
+ }
84
+ /** The whole resolution, in the one order the steps admit. */
85
+ /* Each line is a `protected` method rather than inline code precisely so this reads as the order
86
+ * and nothing else - and so a subclass replacing one step does not have to restate the rest. */
87
+ async _init() {
88
+ await this._discover();
89
+ await this._resolveSelectors();
90
+ this._assertUniqueSelectors();
91
+ await this._resolveConfigs();
92
+ }
93
+ /**
94
+ * Descends from the root, asking each directory's own technology where its children are, and
95
+ * fills `rootPackage` and `packages`.
96
+ *
97
+ * One step, repeated: read the directory's config (which loads whatever plugins it names and
98
+ * decides its platform), then ask *that* platform's `getWorkspace` for the directories below it
99
+ * holding packages. A directory is visited once, and `options.deep` bounds the descent.
100
+ */
101
+ /* **First visit wins.** A provider may legitimately name a directory another one already claimed
102
+ * (two overlapping globs, a symlinked package), and one naming an ancestor would otherwise
103
+ * recurse forever.
104
+ *
105
+ * `deep` bounds the descent for the same reason `findRoot` bounds its climb: a provider computing
106
+ * paths rather than reading them can produce a chain that never ends, and a guessed depth beats a
107
+ * hang with nothing printed. */
108
+ async _discover() {
109
+ const visited = new Set();
110
+ const descend = async (dir, parent, remaining) => {
111
+ const dirname = path.resolve(dir);
112
+ visited.add(dirname);
113
+ const level = await this._readLevel(dirname);
114
+ const platform = this._platformFor(dirname, level.config);
115
+ /** The platform is handed over rather than searched for: the walk that found this directory
116
+ * is what established it, and `Package` re-guessing would be a second answer that can
117
+ * disagree with the first. */
118
+ const pkg = this._createPackage(dirname, platform);
119
+ if (dirname === this.rootDir)
120
+ this.rootPackage = pkg;
121
+ else
122
+ this.packages.push(pkg);
123
+ if (parent) {
124
+ /** `children` is enumerable and `parent` is not: one edge, and only one direction can be
125
+ * the one a walk follows - a `Package` that can be serialized needs the back-reference
126
+ * hidden. */
127
+ parent.children.push(pkg);
128
+ Object.defineProperty(pkg, 'parent', { value: parent, enumerable: false, configurable: true });
129
+ }
130
+ if (remaining <= 0)
131
+ return;
132
+ /** **Its own platform is asked, and nobody else.** A platform that did not claim the
133
+ * directory has no standing to say what is under it. A directory no technology claimed has
134
+ * no `getWorkspace`, hence no children - which is a single-package repository arrived at
135
+ * rather than guessed. */
136
+ for (const child of platform.getWorkspace?.(dirname) ?? []) {
137
+ const childDir = path.resolve(child);
138
+ if (visited.has(childDir))
139
+ continue;
140
+ await descend(childDir, pkg, remaining - 1);
141
+ }
142
+ };
143
+ await descend(this.rootDir, undefined, this.options.deep ?? 10);
144
+ }
145
+ /**
146
+ * Gives every package the selector it answers to - its `.rmanrc "name"` when it declares one, and
147
+ * what its platform says otherwise.
148
+ *
149
+ * `"[glob]"` and `--scope` match this, never the package's name: a name is an *ecosystem's*
150
+ * promise rather than rman's.
151
+ */
152
+ /* **Resolved from a cascade run with no selector at all**, and that is the step whose absence is
153
+ * silent: a `"[pkg-a]"` block is applied on the strength of the selector, and the selector comes
154
+ * from `name`, which is inside the very config being resolved. Reading `name` while applying
155
+ * selector blocks would need the answer in order to find it. So the chain is walked twice -
156
+ * unmarked only, then for real - over one directory cache, which is what keeps the second pass
157
+ * free.
158
+ *
159
+ * `name` is refused inside a glob block for exactly this reason (`_assertSelectorBlocks`), so
160
+ * nothing is lost by the first pass ignoring them. */
161
+ async _resolveSelectors() {
162
+ for (const pkg of [this.rootPackage, ...this.packages]) {
163
+ const config = await this._cascade(pkg, undefined);
164
+ const declared = config.name;
165
+ if (declared !== undefined && (typeof declared !== 'string' || !declared.trim())) {
166
+ throw new Error(`"name" takes the selector this package answers to - "${pkg.dirname}" gave ${typeof declared}.`);
167
+ }
168
+ if (declared !== undefined)
169
+ this._declaredSelectors.add(pkg);
170
+ /** `platformSelector()` is the package's own - the manifest's name, or whatever its platform
171
+ * says addresses it. `Package` already computed it in its constructor; this only replaces it
172
+ * when the config declared one. */
173
+ pkg.selector = declared?.trim() || pkg.platformSelector();
174
+ }
175
+ }
176
+ /**
177
+ * Refuses two packages answering to one selector. The root is exempt - a glob never matches it and
178
+ * `"[/]"` needs no name, so it shares an address with nobody.
179
+ */
180
+ /* Two packages answering to one selector make `"[that]"` and `--scope that` ambiguous *silently* -
181
+ * the config reaches both and a lookup returns the first.
182
+ *
183
+ * `name` cascades like every unmarked key, so one declaration above two packages is the usual way
184
+ * in - and the message says so when that is what happened, since the fix is different. */
185
+ _assertUniqueSelectors() {
186
+ const bySelector = new Map();
187
+ for (const pkg of this.packages) {
188
+ const clash = bySelector.get(pkg.selector);
189
+ if (clash) {
190
+ const cascaded = this._declaredSelectors.has(pkg) && this._declaredSelectors.has(clash);
191
+ throw new Error(`Two packages answer to the selector "${pkg.selector}":\n ${clash.dirname}\n ${pkg.dirname}\n` +
192
+ ` A selector has to be unique - "[${pkg.selector}]" and \`--scope ${pkg.selector}\` ` +
193
+ `cannot mean two packages.` +
194
+ (cascaded
195
+ ? `\n Both got it from one cascading "name" declaration above them; declare it in ` +
196
+ `each package's own ".rmanrc" instead.`
197
+ : ''));
198
+ }
199
+ bySelector.set(pkg.selector, pkg);
200
+ }
201
+ }
202
+ /** Cascades every package's config into `Package.rawConfig`, now that the selectors are known.
203
+ * Still raw - `Repository` is what interpolates it into `Package.config`. */
204
+ async _resolveConfigs() {
205
+ for (const pkg of [this.rootPackage, ...this.packages]) {
206
+ pkg.rawConfig = await this._cascade(pkg, pkg.selector);
207
+ }
208
+ }
209
+ /**
210
+ * Every directory from the root down to `pkg`, layered into one config.
211
+ *
212
+ * Two layers per level: the level's unmarked keys first, then the `"[selector]"` blocks that
213
+ * speak for this package, in the order they were written - later wins, as `overrides` does in
214
+ * eslint and prettier.
215
+ *
216
+ * @param selector what the blocks are matched against; `undefined` skips them, except `"[/]"` for
217
+ * the root, which is structural and needs no selector.
218
+ */
219
+ /* **Unmarked first because it is the widest thing the level says** - it configures that directory
220
+ * *and every package under it*, so it is the level's floor. A selector block written *above* the
221
+ * plain keys still beats them: unmarked is not a fourth selector, it is the layer that also feeds
222
+ * the directories below, and making that depend on key order in the file would be absurd. */
223
+ async _cascade(pkg, selector) {
224
+ const result = {};
225
+ const audience = {
226
+ isRoot: pkg.dirname === this.rootDir,
227
+ selector,
228
+ platform: pkg.platform.name,
229
+ };
230
+ for (const dir of this._dirChain(pkg.dirname)) {
231
+ const level = await this._readLevel(dir);
232
+ mergeConfig(result, this._stripSelectors(level.config));
233
+ for (const block of this._matchingSelectors(level.config, audience)) {
234
+ mergeConfig(result, block);
235
+ }
236
+ }
237
+ return result;
238
+ }
239
+ /** One directory's own config, read once and cached. Plugins the level names are loaded here and
240
+ * accumulated into `plugins`. */
241
+ /* The cache is what makes the two cascade passes affordable - twenty packages sharing a root
242
+ * means that root is read once rather than forty times. It is also what keeps a level's plugins
243
+ * from being loaded a second time. */
244
+ async _readLevel(dirname) {
245
+ const resolved = path.resolve(dirname);
246
+ const hit = this._levels.get(resolved);
247
+ if (hit)
248
+ return hit;
249
+ const result = await this._reader().resolve(resolved, {
250
+ plugins: this.plugins,
251
+ platforms: this.platforms,
252
+ /** **The root alone**, because that is the level they belong to: everything below it inherits
253
+ * whatever the root settled on, and laying them down again there would put a preset's
254
+ * technology ahead of one an intermediate directory declared. */
255
+ presets: resolved === this.rootDir ? (this.options.presets ?? DEFAULT_PRESETS) : undefined,
256
+ });
257
+ /** Whatever this level added, in the order it was loaded - `plugins` appends, never replaces,
258
+ * so a level naming one never means "and drop the ones above me". The technologies a plugin
259
+ * carries are already in `result.platforms`; the reader puts them there, so this walk and the
260
+ * reader cannot end up with two orders. */
261
+ for (const plugin of result.plugins) {
262
+ if (!this.plugins.some(p => p.name === plugin.name))
263
+ this.plugins.push(plugin);
264
+ }
265
+ for (const platform of result.platforms)
266
+ this._addPlatform(platform);
267
+ this._levels.set(resolved, result);
268
+ return result;
269
+ }
270
+ /** Appends a technology unless one already answers to its name. */
271
+ _addPlatform(platform) {
272
+ if (!this.platforms.some(p => p.name === platform.name))
273
+ this.platforms.push(platform);
274
+ }
275
+ /** The reader levels are read with - `options.reader`, or a default one. */
276
+ _reader() {
277
+ return (this._configReader ??= this.options.reader ?? new ConfigReader());
278
+ }
279
+ /** Root, then every directory down to `targetDir`, inclusive. A target outside the root is the
280
+ * root alone. */
281
+ _dirChain(targetDir) {
282
+ const rel = path.relative(this.rootDir, targetDir);
283
+ if (!rel || rel === '.' || rel.startsWith('..'))
284
+ return [this.rootDir];
285
+ const dirs = [this.rootDir];
286
+ let dir = this.rootDir;
287
+ for (const segment of rel.split(path.sep)) {
288
+ dir = path.join(dir, segment);
289
+ dirs.push(dir);
290
+ }
291
+ return dirs;
292
+ }
293
+ /** One config object's own settings - everything that is not a `"[...]"` block. Shallow: a block
294
+ * is dropped whole, and what is inside it is the caller's business. */
295
+ /* **The symbols travel with it, and leaving them behind was a measured loss.** `mergeConfig` reads
296
+ * a key's origin and its `value` chain off the *source* object under `ORIGINS` / `PREVIOUS_VALUES`,
297
+ * so a copy that carries only string keys arrives with neither. Measured on one repository before
298
+ * this line existed: a bad expression in an unmarked key reported `Invalid expression in "group"`
299
+ * with no file, while the identical expression inside a `"[*]"` block reported
300
+ * `... ("group" (.rmanrc.yml))` - because a selector block was merged as itself and only the
301
+ * unmarked layer came through here. Nesting would have spread that to the blocks too. */
302
+ _stripSelectors(config) {
303
+ const result = {};
304
+ for (const [key, value] of Object.entries(config))
305
+ if (!ConfigReader.isSelectorKey(key))
306
+ result[key] = value;
307
+ for (const symbol of Object.getOwnPropertySymbols(config)) {
308
+ Object.defineProperty(result, symbol, Object.getOwnPropertyDescriptor(config, symbol));
309
+ }
310
+ return result;
311
+ }
312
+ /**
313
+ * The layers in `config` that speak for this package, in the order they are merged.
314
+ *
315
+ * `"[/]"` matches the root and nothing else; a glob matches a package's selector and never the
316
+ * root. **A block may hold further selector blocks**, which narrow it: a nested one applies only
317
+ * where its own audience and every audience it sits inside all match.
318
+ */
319
+ /* **A glob never matching the root removes two traps at once**: `"[my-*]"` cannot quietly pick up
320
+ * a repository whose root package is called `my-repo`, and `"[*]"` cannot hand a package-shaped
321
+ * setting to a root with no build directory to apply it to. A root block needs no selector, which
322
+ * is what makes `/` structural.
323
+ *
324
+ * **Nesting is depth-first pre-order, which is the level's own rule applied again rather than a
325
+ * new one**: a block's own keys are its floor, then the blocks written inside it in declaration
326
+ * order, then the next block beside it. So the layer order for
327
+ * `"[platform:node]" { a, "[pkg-*]" { b } }, "[*]" { c }` is `a, b, c` - and the last word is
328
+ * still whatever was written last, *not* whatever is most specific. That is the same cost the
329
+ * dropped specificity ranking already documents (a catch-all written below a narrower block
330
+ * overrides it), and nesting neither adds to it nor asks the tiebreak question again: two blocks
331
+ * that both match are siblings whatever depth they sit at, and declaration order answers them.
332
+ *
333
+ * **A matching block is contributed *stripped*, not whole.** Merging it as it stands put its
334
+ * nested keys into the resolved config as literal `'[pkg-*]'` entries, which is precisely how the
335
+ * old no-op showed up in `rman config` looking as though it had worked.
336
+ *
337
+ * **A platform block speaks for the root too, and only a glob is held off it.** `"[platform:node]"`
338
+ * asks `pkg.platform.name` and the root is a package with a platform, so
339
+ * `"[platform:node]" > "[/]"` is *the root, when the root is a node package* and
340
+ * `"[platform:node]" > "[*]"` is *its packages* - which is the whole of a technology's shared
341
+ * config in one block, `vars` included.
342
+ *
343
+ * A narrower rule was tried first and measured wrong: let a chain reach the root only where it
344
+ * *names* `"[/]"`, keeping a plain `"[platform:node]"` off the root as before. It answers the pair
345
+ * correctly and still breaks the case it was written for - the nested `"[/]"` could not read the
346
+ * `vars` its parent declared (`vars is not defined`), because those are the parent's own keys.
347
+ * Narrowing an audience and hiding the enclosing block's settings from it are different things,
348
+ * and only the first is what nesting means.
349
+ *
350
+ * The cost, stated rather than hidden: a package-shaped setting written **directly** under a
351
+ * platform block now reaches the root as well - the trap that keeps globs off it. It is not the
352
+ * same trap, because neither half of that one applies here: `platform:node` is not a name and
353
+ * cannot match by accident, and it is not a catch-all. And with nesting the spelling for
354
+ * package-shaped settings is `"[platform:node]" > "[*]"`, which says so. */
355
+ _matchingSelectors(config, audience) {
356
+ const matches = [];
357
+ for (const [key, value] of Object.entries(config)) {
358
+ if (!ConfigReader.isSelectorKey(key) || !value || typeof value !== 'object')
359
+ continue;
360
+ const { scope, test } = ConfigReader.parseSelector(key);
361
+ if (!this._speaksFor(scope, test, audience))
362
+ continue;
363
+ const block = value;
364
+ matches.push(this._stripSelectors(block));
365
+ matches.push(...this._matchingSelectors(block, audience));
366
+ }
367
+ return matches;
368
+ }
369
+ /**
370
+ * Whether one selector block speaks for this package.
371
+ *
372
+ * `"[/]"` asks only whether this *is* the root. Everything else matches packages **below** the
373
+ * root and never the root itself, which is addressed structurally or not at all.
374
+ */
375
+ /* **A glob never matching the root removes two traps at once**: `"[my-*]"` cannot quietly pick up
376
+ * a repository whose root package is called `my-repo`, and `"[*]"` cannot hand a package-shaped
377
+ * setting to a root with no build directory to apply it to. Neither trap exists for a platform
378
+ * block - `platform:node` is not a name and cannot match by accident, and it is not a catch-all -
379
+ * so **a platform link answers about the root like any other package**, and what keeps a plain
380
+ * `"[platform:node]"` off the root is `_matchingSelectors`' chain rule rather than a refusal here.
381
+ * Asking `isRoot` first was what made `"[platform:node]" > "[/]"` unanswerable: the pair says *the
382
+ * root, when it is a node package*, and the short-circuit never let the platform question be put.
383
+ *
384
+ * A package no technology claimed carries `basePlatform`, whose name is `''`, so it matches no
385
+ * platform block at all - `parseSelector`'s test refuses an empty name rather than letting an
386
+ * empty selector quietly match it. */
387
+ _speaksFor(scope, test, audience) {
388
+ if (scope === 'root')
389
+ return audience.isRoot;
390
+ if (scope === 'platform')
391
+ return test(audience.platform);
392
+ return !audience.isRoot && audience.selector !== undefined && test(audience.selector);
393
+ }
394
+ /**
395
+ * The platform that claims a directory - the one its config names, or the first loaded platform
396
+ * whose manifest provider recognizes it. `basePlatform` when none does, so a caller needs no
397
+ * guard; a *declared* platform no plugin provides is an error.
398
+ */
399
+ /* `ConfigReader` already decided the name, from a declaration or by asking the loaded plugins, so
400
+ * this only has to find the platform that answers to it. */
401
+ _platformFor(dirname, config) {
402
+ const declared = this._declaredPlatformName(config);
403
+ if (declared === undefined) {
404
+ return this.platforms.find(p => p.manifestProvider.read(dirname)) ?? basePlatform;
405
+ }
406
+ const named = this.platforms.find(p => p.name === declared);
407
+ if (!named) {
408
+ const have = this.platforms.map(p => p.name).filter(Boolean);
409
+ throw new Error(`"platform" names "${declared}" (${this._originOf(config, 'platform')}), which is not a ` +
410
+ `platform this repository has. ` +
411
+ `${have.length ? `Registered: ${have.join(', ')}. ` : 'None is registered. '}` +
412
+ `Contribute it with "platforms", or inherit it with "extends".`);
413
+ }
414
+ /** **A declaration is held to the directory it names.** The failure it replaces is invisible:
415
+ * the manifest reads as nothing, so the package is named after its directory at `0.0.0` and
416
+ * the repository looks fine. The message names the file the platform looked for, because that
417
+ * is the thing to go and check. */
418
+ if (!named.manifestProvider.read(dirname)) {
419
+ throw new Error(`"${dirname}" declares \`platform: '${declared}'\` (${this._originOf(config, 'platform')}), ` +
420
+ `and "${declared}" does not recognize it - it looks for "${named.manifestProvider.fileName}".`);
421
+ }
422
+ return named;
423
+ }
424
+ /** A platform named unmarked or under `"[/]"` - both are the directory saying what it is. */
425
+ /* **An expression is refused rather than read as a literal.** `platform` is consulted before any
426
+ * package exists - it is what decides what a package *is* - so there is no `pkg` for an
427
+ * expression to be about, and interpolation runs long afterwards. Passed through, a `${{ }}` here
428
+ * reached the lookup as its raw text and failed as an unknown platform name, which sends the
429
+ * reader to check their `platforms` instead of the line they wrote.
430
+ *
431
+ * A value that is not a usable string is refused too, rather than read as "declared nothing": the
432
+ * two are opposite answers, and the silent one leaves a directory claimed by whichever technology
433
+ * happened to recognize it. */
434
+ _declaredPlatformName(config) {
435
+ const root = config['[/]'];
436
+ const declared = config.platform ?? (root && typeof root === 'object' ? root.platform : undefined);
437
+ if (declared === undefined)
438
+ return undefined;
439
+ const where = this._originOf(config, 'platform');
440
+ if (typeof declared !== 'string' || !declared.trim()) {
441
+ throw new Error(`"platform" takes a platform's name - ${where} gave ${typeof declared}.`);
442
+ }
443
+ if (declared.includes('${{')) {
444
+ throw new Error(`"platform" cannot be an expression (${where}) - it is read while the packages are still ` +
445
+ `being found, so there is no package for one to be about. Write the name, and use a ` +
446
+ `package's own ".rmanrc" where the answer differs.`);
447
+ }
448
+ return declared.trim();
449
+ }
450
+ /** Which file a key came from, for an error that can be acted on. */
451
+ /* A config is merged from a directory's own forms, an `extends` base, every `"[selector]"` block
452
+ * and one layer per directory before anything reads it, so the key alone leaves the reader
453
+ * searching all of them. `mergeConfig` records one file per key under `ORIGINS`. */
454
+ _originOf(config, key) {
455
+ const origins = config[ORIGINS];
456
+ return origins?.[key] ? `in "${origins[key]}"` : `the "${key}" key`;
457
+ }
458
+ /** Where a `Package` is made, and the only place - override it to build a subclass of `Package`
459
+ * and keep the rest of the walk. */
460
+ _createPackage(dirname, platform) {
461
+ return new Package(dirname, this.options.app, platform);
462
+ }
463
+ }
464
+ /**
465
+ * How a repository is laid out, and who decides.
466
+ *
467
+ * A namespace for the same reason `Manifest` is one: it is what a plugin can *augment*
468
+ * (`declare module 'rman' { namespace Workspace { ... } }`), so whatever this seam grows later
469
+ * arrives as a member here rather than as another top-level export. It merges with the class above,
470
+ * so `Workspace.walk` and `Workspace.create` are reached the same way.
471
+ */
472
+ (function (Workspace) {
473
+ /**
474
+ * **The walk**: descend from `rootDir`, asking each directory's own technology where its children
475
+ * are, and repeating for each answer.
476
+ *
477
+ * One step, applied recursively:
478
+ *
479
+ * 1. take the directory's **declared** platform if it has one, and otherwise the first registered
480
+ * platform whose manifest provider recognizes it (`app.platformFor`, `basePlatform` if none);
481
+ * 2. ask **that** platform's `getWorkspace` for the directories below it holding packages;
482
+ * 3. do the same for each of them.
483
+ *
484
+ * **A declaration wins, and is then held to it.** A platform named for a directory it does not
485
+ * recognize is a statement that is simply untrue, and the failure it would otherwise become is
486
+ * invisible: the manifest reads as nothing, so the package is named after its directory at
487
+ * `0.0.0` and the repository looks like it works. The error names the directory, the platform and
488
+ * the file that platform looked for.
489
+ *
490
+ * The root node always exists - a repository is a package whatever its technology - so this never
491
+ * returns `undefined`. A repository nobody recognizes is a root with no children, which is the
492
+ * single-package answer arrived at rather than guessed.
493
+ *
494
+ * **A directory is visited once.** A provider may legitimately name a directory another one
495
+ * already claimed (two globs overlapping, a symlinked package), and a provider naming an ancestor
496
+ * would otherwise recurse forever. First visit wins, so a package sits where it was first found.
497
+ *
498
+ * `deep` bounds the descent for the same reason `findRoot` bounds its climb: a provider computing
499
+ * paths rather than reading them can produce a chain that never ends, and a guessed depth is
500
+ * better than a hang with nothing printed.
501
+ */
502
+ async function walk(app, rootDir, options) {
503
+ const visited = new Set();
504
+ const descend = async (dir, remaining) => {
505
+ const resolved = path.resolve(dir);
506
+ visited.add(resolved);
507
+ const declared = await options?.declared?.(resolved);
508
+ if (declared && !declared.manifestProvider.read(resolved)) {
509
+ throw new Error(`"${resolved}" declares \`platform: '${declared.name}'\`, and that platform does not ` +
510
+ `recognize it - it looks for "${declared.manifestProvider.fileName}". Either the ` +
511
+ `directory is not a ${declared.name} package, or the declaration belongs one level down.`);
512
+ }
513
+ const platform = declared ?? app.platformFor(resolved);
514
+ const node = { dirname: resolved, platform, children: [] };
515
+ if (remaining <= 0)
516
+ return node;
517
+ /**
518
+ * **Its own platform is asked, and nobody else.** A platform that did not claim the directory
519
+ * has no standing to say what is under it - that was the old first-wins rule, one level up.
520
+ * `basePlatform` has no `getWorkspace`, so a directory no technology claimed has no children,
521
+ * which is the documented behaviour of a repository naming no plugin.
522
+ */
523
+ for (const child of platform.getWorkspace?.(resolved) ?? []) {
524
+ const childDir = path.resolve(child);
525
+ if (visited.has(childDir))
526
+ continue;
527
+ node.children.push(await descend(childDir, remaining - 1));
528
+ }
529
+ return node;
530
+ };
531
+ return descend(rootDir, options?.deep ?? 10);
532
+ }
533
+ Workspace.walk = walk;
534
+ /** Every node below `node`, depth-first, excluding `node` itself - the flat list `Repository`
535
+ * reports as its packages, since the root is not one of its own members. */
536
+ function flatten(node) {
537
+ return node.children.flatMap(child => [child, ...flatten(child)]);
538
+ }
539
+ Workspace.flatten = flatten;
540
+ /**
541
+ * Where the repository starts, decided **without knowing anything about any ecosystem** - it has
542
+ * to be, because the plugins that do know are named in the config file this walk is looking for.
543
+ *
544
+ * Walking up from `from`, stopping after a directory holding `.git`, the root is:
545
+ *
546
+ * 1. the **outermost** directory in that chain holding an `.rmanrc*` - outermost, because a
547
+ * *package* may have its own `.rmanrc` (that is a supported thing), and from inside such a
548
+ * package the nearest one is the package's, not the repository's;
549
+ * 2. otherwise the `.git` directory itself, the ordinary meaning of "repository root";
550
+ * 3. otherwise `from`, which is all that is left to go on.
551
+ *
552
+ * **What "outermost" costs**, since the two cases genuinely conflict and only one can win: a
553
+ * self-contained project nested inside a larger git repository *and sharing its `.git`* resolves
554
+ * to the outer root, not to itself (measured). That is the same repository by any definition git
555
+ * recognizes, so it is the defensible answer - and a nested project with a `.git` of its own is
556
+ * found correctly, because the walk stops there before the outer `.rmanrc` is ever seen (also
557
+ * measured). A per-package `.rmanrc` is the common case and it is the one served.
558
+ */
559
+ function findRoot(from, deep = 10) {
560
+ const chain = [];
561
+ let dir = path.resolve(from);
562
+ let remaining = deep;
563
+ while (remaining-- >= 0 && fs.existsSync(dir)) {
564
+ chain.push(dir);
565
+ if (fs.existsSync(path.join(dir, '.git')))
566
+ break;
567
+ const parent = path.resolve(dir, '..');
568
+ if (parent === dir)
569
+ break;
570
+ dir = parent;
571
+ }
572
+ const withConfig = chain.filter(hasRmanConfig);
573
+ if (withConfig.length)
574
+ return withConfig[withConfig.length - 1];
575
+ const gitRoot = chain.find(d => fs.existsSync(path.join(d, '.git')));
576
+ return gitRoot ?? path.resolve(from);
577
+ }
578
+ Workspace.findRoot = findRoot;
579
+ /** Every file form a `.rmanrc` comes in - `package.json#rman` is deliberately *not* one of them
580
+ * here: it would make the root question npm-shaped again. */
581
+ const CONFIG_FILES = ['.rmanrc', '.rmanrc.yml', '.rmanrc.cjs', '.rmanrc.mjs', '.rmanrc.js'];
582
+ function hasRmanConfig(dir) {
583
+ return CONFIG_FILES.some(name => fs.existsSync(path.join(dir, name)));
584
+ }
585
+ })(Workspace || (Workspace = {}));
@@ -46,7 +46,23 @@ export function toYargsCommand(meta) {
46
46
  * narrower one is the useful one, and this is the single place the widening is admitted -
47
47
  * previously it was an `as` per option read, in every handler.
48
48
  */
49
- handler: meta.handler,
49
+ handler: withCommandPlatforms(meta),
50
+ };
51
+ }
52
+ /**
53
+ * Carries `CommandMetadata.platforms` into argv, where `readPackageFilterOptions` picks it up.
54
+ *
55
+ * Through argv rather than a parameter because `filterPackages` is called by ten *services*, none
56
+ * of which knows which command is running - `options` is all they see. One wrap here reaches every
57
+ * one of them and changes none.
58
+ */
59
+ function withCommandPlatforms(meta) {
60
+ const handler = meta.handler;
61
+ if (!meta.platforms?.length)
62
+ return handler;
63
+ return (args) => {
64
+ args.commandPlatforms = meta.platforms;
65
+ return handler(args);
50
66
  };
51
67
  }
52
68
  /** A command's own name - the first word of its `command` string, before any positional. */