rman 1.3.0 → 2.0.0-beta.2

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 (153) hide show
  1. package/README.md +63 -19
  2. package/cli.d.ts +5 -0
  3. package/cli.js +214 -88
  4. package/commands/build.command.d.ts +177 -3
  5. package/commands/build.command.js +20 -10
  6. package/commands/changed.command.d.ts +80 -3
  7. package/commands/changed.command.js +19 -12
  8. package/commands/changelog.command.d.ts +192 -3
  9. package/commands/changelog.command.js +87 -43
  10. package/commands/config.command.d.ts +44 -3
  11. package/commands/config.command.js +30 -19
  12. package/commands/diff.command.d.ts +37 -3
  13. package/commands/diff.command.js +25 -16
  14. package/commands/exec.command.d.ts +193 -3
  15. package/commands/exec.command.js +60 -58
  16. package/commands/github-release.command.d.ts +154 -3
  17. package/commands/github-release.command.js +67 -37
  18. package/commands/import.command.d.ts +36 -3
  19. package/commands/import.command.js +28 -20
  20. package/commands/info.command.d.ts +35 -7
  21. package/commands/info.command.js +36 -30
  22. package/commands/list.command.d.ts +163 -3
  23. package/commands/list.command.js +109 -71
  24. package/commands/publish.command.d.ts +231 -0
  25. package/commands/publish.command.js +304 -0
  26. package/commands/run.command.d.ts +186 -6
  27. package/commands/run.command.js +26 -72
  28. package/commands/test.command.d.ts +173 -3
  29. package/commands/test.command.js +16 -10
  30. package/commands/version.command.d.ts +317 -3
  31. package/commands/version.command.js +149 -69
  32. package/commands.d.ts +32 -0
  33. package/commands.js +28 -0
  34. package/constants.js +1 -1
  35. package/core/application.d.ts +116 -0
  36. package/core/application.js +143 -0
  37. package/core/command-builder.d.ts +14 -0
  38. package/core/command-builder.js +78 -0
  39. package/core/config.d.ts +140 -19
  40. package/core/config.js +258 -74
  41. package/core/core-services.d.ts +14 -0
  42. package/core/core-services.js +30 -0
  43. package/core/core-targets.d.ts +14 -0
  44. package/core/core-targets.js +16 -0
  45. package/core/custom-command.d.ts +42 -6
  46. package/core/custom-command.js +44 -17
  47. package/core/extends-config.d.ts +13 -5
  48. package/core/extends-config.js +52 -12
  49. package/core/load-config-module.d.ts +28 -0
  50. package/core/load-config-module.js +42 -0
  51. package/core/manifest.d.ts +47 -25
  52. package/core/manifest.js +51 -69
  53. package/core/merge-config.d.ts +33 -34
  54. package/core/merge-config.js +137 -92
  55. package/core/package.d.ts +145 -17
  56. package/core/package.js +128 -36
  57. package/core/plugin-loader.d.ts +65 -0
  58. package/core/plugin-loader.js +234 -0
  59. package/core/plugin.d.ts +135 -90
  60. package/core/plugin.js +70 -173
  61. package/core/publish-target.d.ts +124 -0
  62. package/core/publish-target.js +30 -0
  63. package/core/registry.d.ts +30 -0
  64. package/core/registry.js +47 -0
  65. package/core/repository.d.ts +72 -9
  66. package/core/repository.js +293 -44
  67. package/core/resolve-target.d.ts +1 -1
  68. package/core/resolve-target.js +1 -1
  69. package/core/service.d.ts +49 -0
  70. package/core/service.js +40 -0
  71. package/core/version-scheme.d.ts +23 -1
  72. package/core/version-scheme.js +29 -1
  73. package/core/workspace.d.ts +84 -33
  74. package/core/workspace.js +63 -22
  75. package/index.d.ts +111 -14
  76. package/index.js +85 -9
  77. package/interfaces/rman-config.interface.d.ts +725 -202
  78. package/interfaces/rman-config.interface.js +61 -1
  79. package/package.json +2 -1
  80. package/plugins/builtins.d.ts +44 -0
  81. package/plugins/builtins.js +33 -0
  82. package/plugins/detect.d.ts +78 -0
  83. package/plugins/detect.js +70 -0
  84. package/plugins/node/augmentation/rman.augmentation.d.ts +84 -0
  85. package/plugins/node/augmentation/rman.augmentation.js +1 -0
  86. package/plugins/node/augmentation/system-info.augmentation.d.ts +26 -0
  87. package/plugins/node/augmentation/system-info.augmentation.js +79 -0
  88. package/plugins/node/commands/ci.command.d.ts +131 -0
  89. package/plugins/node/commands/ci.command.js +59 -0
  90. package/plugins/node/commands/clean.command.d.ts +183 -0
  91. package/plugins/node/commands/clean.command.js +73 -0
  92. package/plugins/node/index.d.ts +29 -0
  93. package/plugins/node/index.js +40 -0
  94. package/plugins/node/node-config.interface.d.ts +77 -0
  95. package/plugins/node/node-config.interface.js +7 -0
  96. package/plugins/node/node-manifest.provider.d.ts +68 -0
  97. package/plugins/node/node-manifest.provider.js +125 -0
  98. package/plugins/node/node.platform.d.ts +53 -0
  99. package/plugins/node/node.platform.js +134 -0
  100. package/plugins/node/npm-publish-target.d.ts +73 -0
  101. package/plugins/node/npm-publish-target.js +96 -0
  102. package/plugins/node/services/ci.service.d.ts +47 -0
  103. package/plugins/node/services/ci.service.js +213 -0
  104. package/plugins/node/services/clean.service.d.ts +53 -0
  105. package/plugins/node/services/clean.service.js +237 -0
  106. package/plugins/node/services/publish.service.d.ts +114 -0
  107. package/plugins/node/services/publish.service.js +371 -0
  108. package/plugins/node/services/version-plan.service.d.ts +44 -0
  109. package/plugins/node/services/version-plan.service.js +58 -0
  110. package/plugins/node/utils/npm-view.d.ts +48 -0
  111. package/plugins/node/utils/npm-view.js +71 -0
  112. package/plugins/node/utils/workspace-range.d.ts +26 -0
  113. package/plugins/node/utils/workspace-range.js +28 -0
  114. package/services/change-hash.service.d.ts +2 -2
  115. package/services/change-hash.service.js +2 -2
  116. package/services/changelog.service.d.ts +62 -51
  117. package/services/changelog.service.js +14 -11
  118. package/services/docker-publish.service.d.ts +50 -29
  119. package/services/docker-publish.service.js +43 -20
  120. package/services/exec.service.d.ts +23 -12
  121. package/services/exec.service.js +14 -9
  122. package/services/github-release.service.d.ts +44 -33
  123. package/services/github-release.service.js +13 -10
  124. package/services/import.service.d.ts +25 -14
  125. package/services/import.service.js +9 -5
  126. package/services/list.service.d.ts +62 -10
  127. package/services/list.service.js +62 -15
  128. package/services/run.service.d.ts +22 -13
  129. package/services/run.service.js +262 -223
  130. package/services/version-plan.service.d.ts +27 -4
  131. package/services/version-plan.service.js +42 -15
  132. package/services/version.service.d.ts +31 -11
  133. package/services/version.service.js +29 -13
  134. package/targets/docker.target.d.ts +53 -0
  135. package/targets/docker.target.js +40 -0
  136. package/utils/bin-path.d.ts +6 -7
  137. package/utils/bin-path.js +7 -18
  138. package/utils/branch-guard.d.ts +29 -0
  139. package/utils/branch-guard.js +31 -0
  140. package/utils/exec.d.ts +10 -0
  141. package/utils/exec.js +1 -1
  142. package/utils/logger.d.ts +1 -1
  143. package/utils/logger.js +1 -1
  144. package/utils/package-filter.d.ts +127 -7
  145. package/utils/package-filter.js +197 -16
  146. package/utils/printable-config.d.ts +1 -1
  147. package/utils/printable-config.js +1 -1
  148. package/utils/run-bin.d.ts +10 -0
  149. package/utils/run-bin.js +1 -1
  150. package/utils/run-options.d.ts +97 -0
  151. package/utils/run-options.js +81 -0
  152. package/utils/version-stamp.d.ts +1 -1
  153. package/utils/version-stamp.js +1 -1
@@ -1,11 +1,15 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import colors from 'ansi-colors';
2
3
  import path from 'path';
3
4
  import semver from 'semver';
5
+ import { detectBuiltin } from '../plugins/detect.js';
4
6
  import { GitHelper } from '../utils/git.js';
7
+ import { RmanApplication } from './application.js';
5
8
  import { createFileScope, createReadScope, DEFERRED_PATHS, interpolateConfig, readDirConfig, resolveConfig, } from './config.js';
6
9
  import { Manifest } from './manifest.js';
10
+ import { ORIGINS } from './merge-config.js';
7
11
  import { Package } from './package.js';
8
- import { loadPlugins } from './plugin.js';
12
+ import { loadPlugins, registerPlugin } from './plugin-loader.js';
9
13
  import { Workspace } from './workspace.js';
10
14
  export class Repository extends Package {
11
15
  dirname;
@@ -13,9 +17,10 @@ export class Repository extends Package {
13
17
  packages;
14
18
  cwd;
15
19
  rootPackage;
20
+ /** See the `defineProperty` in the constructor - what detection supplied, when it did. */
21
+ detectedBuiltin;
16
22
  /** Commands the repository's plugins contributed, loaded during `create` because the workspace
17
23
  * providers they bring are needed before any package can be found. `cli.ts` registers them. */
18
- pluginCommands = [];
19
24
  /**
20
25
  * Cached repository scope - see `_repositoryScope`.
21
26
  *
@@ -39,17 +44,40 @@ export class Repository extends Package {
39
44
  * for every package that mentions it.
40
45
  */
41
46
  _readCache = new Map();
42
- constructor(dirname, monorepo, packages,
47
+ /**
48
+ * The application this repository belongs to - its services, its technologies, its logger.
49
+ *
50
+ * **Non-enumerable**, like `_repoScope` and `_git` beside it: the two things that walk a
51
+ * repository are `{...pkg}` spreads and the config scope, and an enumerable back-reference to the
52
+ * whole application would be dragged into both. A `Package` deliberately has no such field at
53
+ * all; a repository is never spread or serialized, which is what makes this one safe - measured,
54
+ * rather than assumed.
55
+ */
56
+ app;
57
+ constructor(app, dirname, monorepo, packages,
43
58
  /** The directory `Repository.create()` was actually invoked from - unlike `dirname` (the
44
59
  * resolved repository root, possibly several levels up), this is where the user's shell
45
60
  * really was. Used by `currentPackage` to scope commands to "the package I'm standing in". */
46
- cwd = dirname) {
47
- super(dirname);
61
+ cwd = dirname,
62
+ /** The root directory's own technology, from the same walk that found the packages - so the
63
+ * repository and its `rootPackage` agree without either searching the registry again. */
64
+ platform) {
65
+ super(dirname, app, platform);
48
66
  this.dirname = dirname;
49
67
  this.monorepo = monorepo;
50
68
  this.packages = packages;
51
69
  this.cwd = cwd;
52
- this.rootPackage = new Package(dirname);
70
+ Object.defineProperty(this, 'app', { value: app, enumerable: false, writable: false });
71
+ /**
72
+ * What detection decided for this repository, or `undefined` when it declared its own
73
+ * technology (or when there was nothing to detect). Carried here because `_resolveConfigs` runs
74
+ * later and every read of the root's config has to agree with the one decision `create` made.
75
+ *
76
+ * Non-enumerable, like `app` and for the same reason: `{...repository}` and `toEqual` both walk
77
+ * a repository, and bookkeeping that shows up there turns spec failures into diffs about it.
78
+ */
79
+ Object.defineProperty(this, 'detectedBuiltin', { value: undefined, enumerable: false, writable: true });
80
+ this.rootPackage = new Package(dirname, app, platform);
53
81
  if (!monorepo)
54
82
  this.packages = [this.rootPackage];
55
83
  // Config resolution can load a `.rmanrc.cjs`/`.mjs`/`.js` module (dynamic `import()`, always
@@ -94,11 +122,22 @@ export class Repository extends Package {
94
122
  * dirty, the reference point decides the rest: without `hash`, `committed`
95
123
  * means committed but not yet in the upstream branch; with `hash`, `changed`
96
124
  * means it differs from that commit. Otherwise a package is `clean`.
125
+ *
126
+ * **Keyed by `Package.selector`**, which is what addresses a package - it was `name`, and the two
127
+ * coincide wherever a technology names its packages. A repository whose does not had every such
128
+ * package answering to `""`, so one entry stood for all of them.
129
+ *
130
+ * **`includeRoot` is opt-in, and the reason is that the root's answer means something different.**
131
+ * Its directory contains every other package, so the same rule - "does a changed file fall under
132
+ * this directory" - reports `dirty` for the root whenever *anything* in the repository is dirty.
133
+ * That is the honest reading of the rule rather than a bug, and it is not what `run --changed`
134
+ * wants, so only a caller that asked for the root gets it (`rman list`'s table, which shows the
135
+ * root as the tree's own row).
97
136
  */
98
137
  async listStatus(options) {
99
138
  const hash = options?.hash;
100
139
  const git = new GitHelper({ cwd: this.dirname });
101
- const packages = this.getPackages();
140
+ const packages = options?.includeRoot ? [this.rootPackage, ...this.getPackages()] : this.getPackages();
102
141
  const belongsTo = (p, files) => files.some(f => !path.relative(p.dirname, f).startsWith('..'));
103
142
  const [dirtyFiles, referenceFiles] = await Promise.all([
104
143
  git.listDirtyFiles({ absolute: true }),
@@ -107,11 +146,11 @@ export class Repository extends Package {
107
146
  const result = {};
108
147
  for (const p of packages) {
109
148
  if (belongsTo(p, dirtyFiles))
110
- result[p.name] = 'dirty';
149
+ result[p.selector] = 'dirty';
111
150
  else if (belongsTo(p, referenceFiles))
112
- result[p.name] = hash ? 'changed' : 'committed';
151
+ result[p.selector] = hash ? 'changed' : 'committed';
113
152
  else
114
- result[p.name] = 'clean';
153
+ result[p.selector] = 'clean';
115
154
  }
116
155
  return result;
117
156
  }
@@ -173,30 +212,96 @@ export class Repository extends Package {
173
212
  * nothing under `getPackages()` is the root, so only its own unmarked config applies.
174
213
  */
175
214
  /**
176
- * Gives every package its `repository` and `parent`, before any config is resolved - a config
177
- * expression or a provider may already want to navigate from a package outwards.
215
+ * Gives every package its `repository`, and hangs the `parent`/`children` tree off the walk that
216
+ * found them - before any config is resolved, since a config expression or a provider may already
217
+ * want to navigate from a package outwards.
218
+ *
219
+ * **The containment is read from the tree rather than recomputed from paths.** It used to be an
220
+ * O(n²) sweep comparing every package's directory against every other's and keeping the longest
221
+ * prefix - which is the same question `Workspace.walk` answers on the way down, asked again
222
+ * afterwards with the answer thrown away. Two places deriving one relationship is two places to
223
+ * disagree; there is one now.
224
+ *
225
+ * **`parent` is defined non-enumerably, `children` is a plain field**, which is the one asymmetry
226
+ * here and it is deliberate: a tree is serialized downwards, so `children` has to be walkable and
227
+ * `parent` must not be, or every `JSON.stringify` is a cycle. The same way `Repository.app` and
228
+ * `ORIGINS` travel.
178
229
  *
179
230
  * A repository's own `repository` is itself, which reads oddly and is the honest answer:
180
231
  * `Repository extends Package`, so the repository *is* a package of its own repository.
181
232
  */
182
- _linkPackages() {
183
- this.repository = this;
184
- this.rootPackage.repository = this;
233
+ _linkPackages(tree, nodes, packages) {
234
+ /** Non-enumerable everywhere, including on the repository itself - see `Package.repository`.
235
+ * `writable` because `import` grafts an external repository's packages onto this one. */
236
+ const link = (pkg) => {
237
+ Object.defineProperty(pkg, 'repository', { value: this, enumerable: false, configurable: true, writable: true });
238
+ };
239
+ link(this);
240
+ link(this.rootPackage);
241
+ for (const pkg of this.packages)
242
+ link(pkg);
243
+ /** The walk visits a directory once, so one node is one package and this map is a bijection -
244
+ * which is what lets the edges below be read off the tree instead of guessed from paths. */
245
+ const byNode = new Map(nodes.map((node, i) => [node, packages[i]]));
246
+ byNode.set(tree, this.rootPackage);
247
+ for (const node of [tree, ...nodes]) {
248
+ const pkg = byNode.get(node);
249
+ for (const childNode of node.children) {
250
+ const child = byNode.get(childNode);
251
+ pkg.children.push(child);
252
+ Object.defineProperty(child, 'parent', { value: pkg, enumerable: false, configurable: true });
253
+ }
254
+ }
255
+ /** `Repository extends Package` while holding a separate `rootPackage` for the same directory,
256
+ * so both are truthfully the root - they share the one array rather than each getting a copy
257
+ * that could drift. */
258
+ Object.defineProperty(this, 'children', { value: this.rootPackage.children, enumerable: true });
259
+ }
260
+ /**
261
+ * Gives every package the selector a `"[glob]"` block and `--scope` match it by, and refuses two
262
+ * packages that would answer to the same one.
263
+ *
264
+ * **Its own step, before any config is resolved by a selector**, which is the ordering that makes
265
+ * the rest work: `_resolveConfigs` asks `resolveConfig` for each package *by selector*, so an
266
+ * address assigned afterwards would be applied to nothing.
267
+ *
268
+ * **`name` comes from the unmarked cascade only** - the same read `platform` gets, from the same
269
+ * cache, for the same reason. A `"[glob]"` block cannot set it (`assertSelectorBlocks` refuses
270
+ * one) because the glob matches the very thing the block would be setting.
271
+ *
272
+ * **Uniqueness is checked, and the cascade is the mistake it usually catches.** `name` cascades
273
+ * like every unmarked key, so one declaration above two packages gives both the same address -
274
+ * and the failure would otherwise be silent in the worst way: the config reaches both and
275
+ * `getPackage` returns whichever came first. The message names both directories, and says the
276
+ * cascade out loud when the two got it from one declaration.
277
+ */
278
+ async _assignSelectors(rootDir, cache) {
279
+ const declaredBy = new Map();
280
+ for (const pkg of [this.rootPackage, ...this.packages]) {
281
+ const config = await resolveConfig(rootDir, pkg.dirname, cache, undefined);
282
+ const declared = config.name;
283
+ if (declared !== undefined && (typeof declared !== 'string' || !declared.trim())) {
284
+ throw new Error(`"name" takes the selector this package answers to - "${pkg.dirname}" gave ${typeof declared}.`);
285
+ }
286
+ pkg.selector = declared?.trim() || pkg.platformSelector();
287
+ declaredBy.set(pkg, declared !== undefined);
288
+ }
289
+ /** The root is left out: a glob never matches it and `"[/]"` needs no name, so it shares an
290
+ * address with nobody - see `Package.selector`. */
291
+ const bySelector = new Map();
185
292
  for (const pkg of this.packages) {
186
- pkg.repository = this;
187
- /** The deepest package that strictly contains it - the root for an ordinary member, an
188
- * enclosing package for a nested one. Longest containing path wins, the same rule
189
- * `currentPackage` uses to resolve "the package I am standing in". */
190
- let parent = this.monorepo ? this.rootPackage : undefined;
191
- for (const other of this.packages) {
192
- if (other === pkg)
193
- continue;
194
- const rel = path.relative(other.dirname, pkg.dirname);
195
- const contains = !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
196
- if (contains && (!parent || other.dirname.length > parent.dirname.length))
197
- parent = other;
293
+ const clash = bySelector.get(pkg.selector);
294
+ if (clash) {
295
+ const cascaded = declaredBy.get(pkg) && declaredBy.get(clash);
296
+ throw new Error(`Two packages answer to the selector "${pkg.selector}":\n ${clash.dirname}\n ${pkg.dirname}\n` +
297
+ ` A selector has to be unique - "[${pkg.selector}]" and \`--scope ${pkg.selector}\` ` +
298
+ `cannot mean two packages.` +
299
+ (cascaded
300
+ ? `\n Both got it from one cascading "name" declaration above them; declare it in ` +
301
+ `each package's own ".rmanrc" instead.`
302
+ : ''));
198
303
  }
199
- pkg.parent = pkg === this.rootPackage ? undefined : parent;
304
+ bySelector.set(pkg.selector, pkg);
200
305
  }
201
306
  }
202
307
  async _resolveConfigs() {
@@ -208,15 +313,18 @@ export class Repository extends Package {
208
313
  * second `rawConfig` copy of every package's config for that one reader; measured identical.
209
314
  */
210
315
  /**
211
- * The root is resolved **with its name**, like every other package. Not because a glob could
212
- * match it - `"[*]"` and every other name pattern speak only to the packages below - but because
213
- * `resolveConfig` needs a name to run `matchingSelectors` at all, and `"[/]"` is a selector.
214
- * Passing none would silently drop the root's own block.
316
+ * **By selector, not by name** - `"[glob]"` matches `Package.selector`, which is the package's
317
+ * own `.rmanrc "name"` when it assigned one and its platform's answer otherwise. They coincide
318
+ * for every Node repository; they are not the same question, and `name` was the wrong one to
319
+ * ask, since a package having one at all is an ecosystem's promise rather than rman's.
320
+ *
321
+ * The root passes its own too, although no glob can match it: `"[/]"` is applied on the
322
+ * strength of the target *being* the root and needs no selector at all (see `resolveConfig`).
215
323
  */
216
- const rootRaw = await resolveConfig(this.dirname, this.dirname, cache, this.rootPackage.name);
324
+ const rootRaw = await resolveConfig(this.dirname, this.dirname, cache, this.rootPackage.selector, this.detectedBuiltin);
217
325
  this.config = interpolateConfig(rootRaw, this.configScope(this.rootPackage), { skip: DEFERRED_PATHS });
218
326
  for (const pkg of this.packages) {
219
- const raw = await resolveConfig(this.dirname, pkg.dirname, cache, pkg.name);
327
+ const raw = await resolveConfig(this.dirname, pkg.dirname, cache, pkg.selector, this.detectedBuiltin);
220
328
  pkg.config = interpolateConfig(raw, this.configScope(pkg), { skip: DEFERRED_PATHS });
221
329
  }
222
330
  if (this.monorepo)
@@ -236,7 +344,7 @@ export class Repository extends Package {
236
344
  // than refusing it - so the scope has to survive one too.
237
345
  const name = pkg.name ?? '';
238
346
  /** Splitting `@scope/name` is npm's convention, not a universal - the provider decides. */
239
- const { scope: nameScope, unscopedName } = Manifest.splitName(pkg.dirname, name);
347
+ const { scope: nameScope, unscopedName } = Manifest.splitName(this.app, pkg.dirname, name);
240
348
  const scope = {
241
349
  name,
242
350
  scope: nameScope,
@@ -384,26 +492,106 @@ export class Repository extends Package {
384
492
  * otherwise: the plugins that know what a package is are named in the config file this step
385
493
  * is looking for.
386
494
  * 2. **Load the plugins** the root's config names, which registers their workspace providers
387
- * (and their commands, handed on via `pluginCommands` - `cli.ts` registers those).
495
+ * (their commands arrive through `.rmanrc "commands"`, which `cli.ts` reads).
388
496
  * 3. **Ask the providers** for the layout. None recognizing it means a repository that is itself
389
497
  * the one package.
390
498
  *
391
499
  * **A repository whose `.rmanrc` names no plugin has no packages beyond itself**, and that is the
392
500
  * boundary working rather than failing: `workspaces` in a `package.json` is npm's idea, so it
393
- * takes `plugins: ['rman-node']` to be read as one.
501
+ * takes `plugins: ['node']` - or detection reading the directory as a Node one - for it to be
502
+ * read as a workspace at all.
394
503
  */
395
504
  static async create(root, options) {
396
505
  const from = root || process.cwd();
397
506
  const rootDir = Workspace.findRoot(from, options?.deep ?? 10);
507
+ /**
508
+ * One application per repository, made here unless the caller brought one.
509
+ *
510
+ * `runCli` passes its own so that `--log-level` reaches the logger; a spec that only wants a
511
+ * repository lets this make one, which is also what keeps two repositories in a single process
512
+ * from sharing anything - the thing that used to need five `clear*()` calls before every test.
513
+ */
514
+ const app = options?.app ?? new RmanApplication();
398
515
  /** The root's own config, raw: `plugins` is a list of package names, so it needs neither the
399
516
  * package list (which does not exist yet) nor expression interpolation. */
400
- const rootConfig = await readDirConfig(rootDir);
401
- const pluginCommands = await loadPlugins(rootDir, rootConfig);
402
- const layout = Workspace.resolve(rootDir);
403
- const packages = (layout?.packageDirs ?? []).map(dir => new Package(dir));
404
- const repo = new Repository(layout?.root ?? rootDir, packages.length > 0, packages, from);
405
- repo.pluginCommands = pluginCommands;
406
- repo._linkPackages();
517
+ const declared = await readDirConfig(rootDir);
518
+ /**
519
+ * **What this repository looks like, when nothing said** - the other half of shipping the
520
+ * built-ins in the box. See `detectBuiltin`.
521
+ *
522
+ * **Two conditions, and the second is the one that is easy to miss.** The config declaring no
523
+ * `plugins` is not the same as the *repository* having no technology: a programmatic caller -
524
+ * and every spec in this suite - registers one straight onto the application without writing a
525
+ * config at all. Guessing on top of that registers a second technology, and the first provider
526
+ * that recognizes a directory decides whether it holds a package. Measured with only the config
527
+ * condition: ten specs changed answer, seven of them about config cascading and three about
528
+ * `publish`'s flags.
529
+ *
530
+ * **`platforms`, not `plugins`, and the difference is what detection produces.** What would be
531
+ * added here is a *platform*, so what must not already be there is a platform - a plugin that
532
+ * only registers a command says nothing about which directories hold packages, and letting it
533
+ * suppress the guess would leave a Node repository undetected for having added a command.
534
+ *
535
+ * **A root `platform` counts as having said something**, like `plugins: []` does. Detection
536
+ * exists for a repository that stated nothing; one naming its technology has stated the very
537
+ * thing detection would be guessing at, and guessing anyway would register a *second* platform
538
+ * beside the declared one - which then competes for every directory the declaration did not
539
+ * cover.
540
+ *
541
+ * Decided **once**, here, and handed to every read that has to agree - `readDirConfig` has no
542
+ * business knowing about an application.
543
+ */
544
+ const saidSomething = declared.plugins !== undefined || declared.platform !== undefined;
545
+ const detected = !saidSomething && app.platforms.size === 0 ? await detectBuiltin(rootDir) : undefined;
546
+ const rootConfig = detected ? await readDirConfig(rootDir, { inject: detected }) : declared;
547
+ /**
548
+ * **Said out loud, because a guess the reader cannot see is one they cannot correct.**
549
+ *
550
+ * To **stderr**, not through `app.logger`: that writes with `console.log`, and `rman list
551
+ * --json` has to stay a parseable document on stdout - measured, its output is pure JSON, and
552
+ * one line of prose in front of it breaks every `| jq`. The same reason `--help`'s degradation
553
+ * notice goes to stderr. Not at `silent`, where the caller asked for no narration.
554
+ */
555
+ if (detected && app.logger.level !== 'silent') {
556
+ const line = `${detected.name} repository detected (${detected.because}) - ` +
557
+ `write \`plugins: ['${detected.name}']\` in .rmanrc to state it, or \`plugins: []\` for none.`;
558
+ console.error(process.stderr.isTTY ? colors.gray(line) : line);
559
+ }
560
+ await loadPlugins(app, rootConfig);
561
+ /**
562
+ * **Its own cache, deliberately not shared with `_resolveConfigs`'.**
563
+ *
564
+ * Both cache `readDirConfig` per directory, and the two reads of the *root* are not the same
565
+ * read: `_resolveConfigs` passes `detectedBuiltin` as `inject` and this one passes nothing,
566
+ * since a built-in's contribution has no bearing on which platform a directory declares. The
567
+ * cache is keyed by directory alone, so one shared map would hand whichever ran first to the
568
+ * other - and for the root that is a config with or without the whole node built-in merged in.
569
+ *
570
+ * The cost is one extra read per directory in the chain, on a repository that is about to read
571
+ * every one of them again per package anyway.
572
+ */
573
+ const platformCache = new Map();
574
+ /**
575
+ * **The walk**, which is where the package list comes from now - descending from the root,
576
+ * asking each directory's own technology where its children are. `Workspace.resolve` asked the
577
+ * root once, through whichever platform recognized it first; the tree is the shape discovery
578
+ * actually has, and it is what makes a nested package of another technology findable at all.
579
+ */
580
+ const tree = await Workspace.walk(app, rootDir, {
581
+ deep: options?.deep,
582
+ declared: dir => declaredPlatformAt(app, rootDir, dir, platformCache),
583
+ });
584
+ const nodes = Workspace.flatten(tree);
585
+ const packages = nodes.map(node => new Package(node.dirname, app, node.platform));
586
+ const repo = new Repository(app, tree.dirname, packages.length > 0, packages, from, tree.platform);
587
+ repo._linkPackages(tree, nodes, packages);
588
+ /** Before `_resolveConfigs`, because a `"[glob]"` block is matched against the selector - so
589
+ * the addresses have to be settled before anything is resolved by them. */
590
+ await repo._assignSelectors(rootDir, platformCache);
591
+ /** The application is what the plugins registered into a moment ago; from here on it can hand
592
+ * out services, which need the repository to work on. */
593
+ repo.detectedBuiltin = detected;
594
+ app.attachRepository(repo);
407
595
  return Repository._init(repo);
408
596
  }
409
597
  /** Finishes constructing `repo` with the async work a constructor can't do itself - resolving
@@ -415,3 +603,64 @@ export class Repository extends Package {
415
603
  return repo;
416
604
  }
417
605
  }
606
+ /**
607
+ * The platform `dir` declares in its cascaded `.rmanrc "platform"`, loaded if it has to be -
608
+ * `undefined` when the directory declares none, so the walk falls back to its guess.
609
+ *
610
+ * **The unmarked cascade only**, which is what `resolveConfig` gives with no package name: a
611
+ * selector matches a package *name*, and this runs while the packages are still being found - the
612
+ * name is not known yet, and it is read *from* a manifest whose reader this key decides. So
613
+ * `"[/]"` reaches the root (its directory is the repository root, which needs no name) and a glob
614
+ * block contributes nothing here, which is the honest answer rather than a half-applied one.
615
+ */
616
+ async function declaredPlatformAt(app, rootDir, dir, cache) {
617
+ const config = await resolveConfig(rootDir, dir, cache);
618
+ const declared = config.platform;
619
+ if (declared === undefined)
620
+ return undefined;
621
+ if (typeof declared !== 'string' || !declared.trim()) {
622
+ throw new Error(`"platform" takes a platform's name - ${originOf(config)} gave ${typeof declared}.`);
623
+ }
624
+ /**
625
+ * **An expression is refused rather than read as a literal.** `platform` is consulted before any
626
+ * package exists - it is what decides what a package *is* - so there is no `pkg` for an
627
+ * expression to be about, and `interpolateConfig` runs long afterwards. Passed through, a
628
+ * `${{ }}` here reached the lookup below as the raw text and failed as an unknown platform name,
629
+ * which sends the reader to check their `plugins`.
630
+ */
631
+ if (declared.includes('${{')) {
632
+ throw new Error(`"platform" cannot be an expression (${originOf(config)}) - it is read while ` +
633
+ `the packages are still being found, so there is no package for one to be about. Write the ` +
634
+ `name, and use a package's own ".rmanrc" where the answer differs.`);
635
+ }
636
+ const registered = [...app.platforms].find(p => p.name === declared);
637
+ if (registered)
638
+ return registered;
639
+ /**
640
+ * **A built-in is loaded on the strength of being named**, from any level - `platform: 'node'` is
641
+ * enough, and it is what a repository holding one Node package among others writes.
642
+ *
643
+ * `platform()` and not `contribute()`, and that split is why the two halves exist: what arrives
644
+ * is the technology alone. Commands and publish targets come from root `plugins`, because they
645
+ * are repository-wide - a Node package inside a Cargo repository wants npm's manifest read, not
646
+ * an `rman clean` that would sweep the whole tree.
647
+ */
648
+ const { BUILTIN_PLUGINS, builtinPluginNames } = await import('../plugins/builtins.js');
649
+ const builtin = BUILTIN_PLUGINS[declared];
650
+ if (builtin) {
651
+ const platform = builtin.platform();
652
+ registerPlugin(app, platform);
653
+ return platform;
654
+ }
655
+ const have = [...app.platforms].map(p => p.name).filter(Boolean);
656
+ throw new Error(`"platform" names "${declared}" (${originOf(config)}), which is not a platform ` +
657
+ `this repository has. ${have.length ? `Registered: ${have.join(', ')}. ` : 'None is registered. '}` +
658
+ `rman ships ${builtinPluginNames().join(', ')}; anything else arrives through "plugins".`);
659
+ }
660
+ /** Which file the `platform` key came from, for an error that can be acted on - `mergeConfig`
661
+ * records one per key under `ORIGINS`, and a config is merged from a directory's own four forms,
662
+ * an `extends` base and one layer per directory before anything reads it. */
663
+ function originOf(config) {
664
+ const origins = config[ORIGINS];
665
+ return origins?.platform ? `in "${origins.platform}"` : 'the "platform" key';
666
+ }
@@ -15,7 +15,7 @@ export declare function resolveConfigTarget(target: string, from: string, label:
15
15
  * repository cannot resolve it.
16
16
  *
17
17
  * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
18
- * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
18
+ * `rman ci` work on a fresh clone - the command came from `rman-node` then, and `ci` exists to create
19
19
  * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
20
20
  * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
21
21
  * it installed in this repository?`, which was true and useless.
@@ -39,7 +39,7 @@ export function resolveConfigTarget(target, from, label) {
39
39
  * repository cannot resolve it.
40
40
  *
41
41
  * A globally installed rman's siblings *are* the globally installed packages, so this is what makes
42
- * `rman ci` work on a fresh clone - the command comes from `rman-node`, and `ci` exists to create
42
+ * `rman ci` work on a fresh clone - the command came from `rman-node` then, and `ci` exists to create
43
43
  * the very `node_modules` the plugin would otherwise have to be found in. Measured: with both
44
44
  * installed globally, a clone answered `"plugins" target "rman-node" could not be resolved ... is
45
45
  * it installed in this repository?`, which was true and useless.
@@ -0,0 +1,49 @@
1
+ import type { Logger } from '../utils/logger.js';
2
+ import type { RmanApplication } from './application.js';
3
+ import type { Repository } from './repository.js';
4
+ /**
5
+ * **The one service shape.** Everything that does work on a repository is a class extending this,
6
+ * constructed once per `RmanApplication` and reached through `app.getService(name)`.
7
+ *
8
+ * rman's services were `export namespace` blocks, and the split between those and the two classes
9
+ * (`VersionPlanService`, `VersionScheme`) was historical rather than principled: whatever needed to
10
+ * be *extended* became a class, and the rest stayed a namespace. Three consequences, all of them
11
+ * measured in this repository:
12
+ *
13
+ * - **A namespace that needs state can only have global state.** `RunService` is a namespace and
14
+ * acquired three module-level variables - `stepSources` and two "already warned" sets - which is
15
+ * why a warning printed for one repository was suppressed for the next one in the same process.
16
+ * - **A namespace cannot be extended.** The two seams a plugin has to specialize
17
+ * (`NodeVersionPlanService extends VersionPlanService`) are precisely the two that are classes.
18
+ * - **A namespace has to be stubbed by mutating the module**, and CLAUDE.md records that failing:
19
+ * a spec that captured a core function at module scope and restored it put the *un-augmented*
20
+ * version back for the rest of the process and broke a spec two files away. An instance is
21
+ * rebuilt per application, so a stub dies with it.
22
+ *
23
+ * **`repository` is no longer a parameter.** Nearly every service method took it first; it now
24
+ * comes from the application, which is what made the argument redundant in the first place.
25
+ *
26
+ * Pure functions of their arguments stay plain exported functions rather than becoming services -
27
+ * `ConventionalCommitsService.parseSubject('feat: x')` parses a string and will never hold state,
28
+ * and making it `app.getService('conventionalCommits').parseSubject(...)` would be ceremony that
29
+ * also puts an application between a caller and a parser. The line is whether it needs the
30
+ * repository.
31
+ */
32
+ export declare abstract class Service {
33
+ protected readonly app: RmanApplication;
34
+ constructor(app: RmanApplication);
35
+ protected get repository(): Repository;
36
+ protected get logger(): Logger;
37
+ }
38
+ /**
39
+ * Every service the application can hand out, by name.
40
+ *
41
+ * Declaration-merged, like `RmanConfigKeys`: the core declares its own here, and a plugin adds its
42
+ * own from its own package with `declare module 'rman'`. That is what keeps `getService` typed -
43
+ * a bare `Map<string, Service>` would need a cast at every call and would turn a misspelled name
44
+ * into a runtime failure instead of a compile error.
45
+ */
46
+ export interface ServiceMap {
47
+ }
48
+ /** How a service is built when the application first needs it. */
49
+ export type ServiceFactory<T> = (app: RmanApplication) => T;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * **The one service shape.** Everything that does work on a repository is a class extending this,
3
+ * constructed once per `RmanApplication` and reached through `app.getService(name)`.
4
+ *
5
+ * rman's services were `export namespace` blocks, and the split between those and the two classes
6
+ * (`VersionPlanService`, `VersionScheme`) was historical rather than principled: whatever needed to
7
+ * be *extended* became a class, and the rest stayed a namespace. Three consequences, all of them
8
+ * measured in this repository:
9
+ *
10
+ * - **A namespace that needs state can only have global state.** `RunService` is a namespace and
11
+ * acquired three module-level variables - `stepSources` and two "already warned" sets - which is
12
+ * why a warning printed for one repository was suppressed for the next one in the same process.
13
+ * - **A namespace cannot be extended.** The two seams a plugin has to specialize
14
+ * (`NodeVersionPlanService extends VersionPlanService`) are precisely the two that are classes.
15
+ * - **A namespace has to be stubbed by mutating the module**, and CLAUDE.md records that failing:
16
+ * a spec that captured a core function at module scope and restored it put the *un-augmented*
17
+ * version back for the rest of the process and broke a spec two files away. An instance is
18
+ * rebuilt per application, so a stub dies with it.
19
+ *
20
+ * **`repository` is no longer a parameter.** Nearly every service method took it first; it now
21
+ * comes from the application, which is what made the argument redundant in the first place.
22
+ *
23
+ * Pure functions of their arguments stay plain exported functions rather than becoming services -
24
+ * `ConventionalCommitsService.parseSubject('feat: x')` parses a string and will never hold state,
25
+ * and making it `app.getService('conventionalCommits').parseSubject(...)` would be ceremony that
26
+ * also puts an application between a caller and a parser. The line is whether it needs the
27
+ * repository.
28
+ */
29
+ export class Service {
30
+ app;
31
+ constructor(app) {
32
+ this.app = app;
33
+ }
34
+ get repository() {
35
+ return this.app.repository;
36
+ }
37
+ get logger() {
38
+ return this.app.logger;
39
+ }
40
+ }
@@ -16,7 +16,7 @@ export type ChangeKind = 'fix' | 'feature' | 'breaking';
16
16
  * "is this version on the registry yet", `"workspace:"` range rewriting all assume versions that
17
17
  * can be ordered and incremented. This is the seam for an ecosystem that numbers differently (PEP
18
18
  * 440, a date-based scheme, a build counter), and it is a *meaning* seam rather than a storage one:
19
- * `ManifestProvider` answers "where is the version written", this answers "what does the next one
19
+ * `Plugin`'s manifest members answers "where is the version written", this answers "what does the next one
20
20
  * look like".
21
21
  *
22
22
  * **`bumpNames` and `bumpFor` are the interesting part.** `fix:` -> patch, `feat:` -> minor,
@@ -64,6 +64,20 @@ export declare abstract class VersionScheme {
64
64
  }): string;
65
65
  /** Is this version a preview rather than a release? `github-release` reads it. */
66
66
  abstract isPrerelease(version: string): boolean;
67
+ /**
68
+ * Which prerelease line this version belongs to - `'beta'` for `2.0.0-beta.1` - or `undefined`
69
+ * when it is not a preview, or is one with no identifier to name (`2.0.0-1`).
70
+ *
71
+ * **The one thing a preview needs beyond "is it one", and it is a *name*, which is why it is
72
+ * here rather than read out of the version with a regex by whoever wants it.** npm's publish
73
+ * target derives its dist-tag from this, so a beta lands on `beta` instead of on `latest`; the
74
+ * identifier is written in the version itself, so that is a reading rather than a guess.
75
+ *
76
+ * Implemented, not abstract, and returning `undefined` by default: a scheme whose previews have
77
+ * no name (or which has no previews at all) is answering honestly, and the caller's job is to
78
+ * say so rather than invent one. `SemverScheme` overrides it.
79
+ */
80
+ prereleaseId(version: string): string | undefined;
67
81
  /**
68
82
  * The highest of `versions` - a group's current version is the highest among its members, and a
69
83
  * monorepo root's release identity the highest among the groups.
@@ -111,6 +125,14 @@ export declare class SemverScheme extends VersionScheme {
111
125
  preid?: string;
112
126
  }): string;
113
127
  isPrerelease(version: string): boolean;
128
+ /**
129
+ * semver's first prerelease identifier, when it is a word: `2.0.0-beta.1` -> `'beta'`.
130
+ *
131
+ * **`undefined` for a numeric-only prerelease** (`2.0.0-1`, whose identifiers are `[1]`), because
132
+ * there is no name there to use - and a caller turning that into a dist-tag called `1` would be
133
+ * inventing one. Same answer for a release, which has no prerelease at all.
134
+ */
135
+ prereleaseId(version: string): string | undefined;
114
136
  }
115
137
  /**
116
138
  * The default, and what every package gets unless something says otherwise - so nothing about