rman 1.0.12 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +90 -70
  2. package/cli.js +226 -14
  3. package/commands/build.command.js +1 -0
  4. package/commands/changed.command.js +2 -2
  5. package/commands/changelog.command.js +13 -15
  6. package/commands/config.command.js +61 -0
  7. package/commands/diff.command.js +9 -4
  8. package/commands/exec.command.js +2 -8
  9. package/commands/github-release.command.js +1 -0
  10. package/commands/info.command.d.ts +9 -0
  11. package/commands/info.command.js +12 -2
  12. package/commands/run.command.js +5 -8
  13. package/commands/test.command.js +1 -0
  14. package/commands/version.command.js +53 -14
  15. package/constants.js +1 -1
  16. package/core/config.d.ts +265 -17
  17. package/core/config.js +651 -76
  18. package/core/custom-command.d.ts +133 -0
  19. package/core/custom-command.js +99 -0
  20. package/core/extends-config.d.ts +27 -0
  21. package/core/extends-config.js +89 -0
  22. package/core/manifest.d.ts +222 -0
  23. package/core/manifest.js +150 -0
  24. package/core/merge-config.d.ts +70 -0
  25. package/core/merge-config.js +193 -0
  26. package/core/package.d.ts +73 -7
  27. package/core/package.js +86 -24
  28. package/core/plugin.d.ts +112 -0
  29. package/core/plugin.js +189 -0
  30. package/core/repository.d.ts +91 -1
  31. package/core/repository.js +277 -132
  32. package/core/resolve-target.d.ts +12 -0
  33. package/core/resolve-target.js +33 -0
  34. package/core/run-step.d.ts +75 -0
  35. package/core/run-step.js +1 -0
  36. package/core/version-scheme.d.ts +134 -0
  37. package/core/version-scheme.js +148 -0
  38. package/core/workspace.d.ts +68 -0
  39. package/core/workspace.js +83 -0
  40. package/index.d.ts +55 -8
  41. package/index.js +42 -7
  42. package/interfaces/rman-config.interface.d.ts +222 -46
  43. package/package.json +16 -7
  44. package/services/change-hash.service.d.ts +88 -0
  45. package/services/change-hash.service.js +112 -0
  46. package/services/changelog.service.d.ts +8 -13
  47. package/services/changelog.service.js +12 -11
  48. package/services/conventional-commits.service.d.ts +73 -0
  49. package/services/conventional-commits.service.js +116 -0
  50. package/services/docker-publish.service.js +1 -1
  51. package/services/exec.service.js +1 -1
  52. package/services/github-release.service.d.ts +2 -2
  53. package/services/github-release.service.js +10 -5
  54. package/services/list.service.js +5 -2
  55. package/services/run.service.d.ts +112 -6
  56. package/services/run.service.js +265 -89
  57. package/services/system-info.d.ts +22 -7
  58. package/services/system-info.js +8 -23
  59. package/services/version-plan.service.d.ts +244 -0
  60. package/services/version-plan.service.js +414 -0
  61. package/services/version.service.d.ts +102 -82
  62. package/services/version.service.js +226 -434
  63. package/services.d.ts +5 -3
  64. package/services.js +5 -3
  65. package/utils/bin-path.d.ts +59 -0
  66. package/utils/bin-path.js +82 -0
  67. package/utils/child-tracker.d.ts +16 -0
  68. package/utils/child-tracker.js +30 -0
  69. package/utils/exec.d.ts +13 -2
  70. package/utils/exec.js +17 -17
  71. package/utils/git.d.ts +9 -3
  72. package/utils/git.js +10 -2
  73. package/utils/package-filter.d.ts +33 -2
  74. package/utils/package-filter.js +47 -7
  75. package/utils/printable-config.d.ts +15 -0
  76. package/utils/printable-config.js +42 -0
  77. package/utils/release-version.js +3 -3
  78. package/utils/run-bin.d.ts +46 -0
  79. package/utils/run-bin.js +63 -0
  80. package/utils/version-stamp.d.ts +14 -6
  81. package/utils/version-stamp.js +25 -13
  82. package/commands/ci.command.js +0 -30
  83. package/commands/clean.command.d.ts +0 -3
  84. package/commands/clean.command.js +0 -36
  85. package/commands/publish.command.d.ts +0 -3
  86. package/commands/publish.command.js +0 -225
  87. package/rmanrc.schema.json +0 -392
  88. package/services/ci.service.d.ts +0 -40
  89. package/services/ci.service.js +0 -204
  90. package/services/clean.service.d.ts +0 -42
  91. package/services/clean.service.js +0 -226
  92. package/services/publish.service.d.ts +0 -79
  93. package/services/publish.service.js +0 -273
  94. package/utils/change-hash.d.ts +0 -68
  95. package/utils/change-hash.js +0 -98
  96. package/utils/conventional-commits.d.ts +0 -52
  97. package/utils/conventional-commits.js +0 -90
  98. package/utils/npm-run-path.d.ts +0 -67
  99. package/utils/npm-run-path.js +0 -63
  100. package/utils/workspace-range.d.ts +0 -17
  101. package/utils/workspace-range.js +0 -28
  102. /package/commands/{ci.command.d.ts → config.command.d.ts} +0 -0
@@ -1,17 +1,44 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import glob from 'fast-glob';
3
- import fs from 'fs';
4
2
  import path from 'path';
5
3
  import semver from 'semver';
6
4
  import { GitHelper } from '../utils/git.js';
7
- import { interpolateConfig, resolveConfig } from './config.js';
5
+ import { createFileScope, createReadScope, DEFERRED_PATHS, interpolateConfig, readDirConfig, resolveConfig, } from './config.js';
6
+ import { Manifest } from './manifest.js';
8
7
  import { Package } from './package.js';
8
+ import { loadPlugins } from './plugin.js';
9
+ import { Workspace } from './workspace.js';
9
10
  export class Repository extends Package {
10
11
  dirname;
11
12
  monorepo;
12
13
  packages;
13
14
  cwd;
14
15
  rootPackage;
16
+ /** Commands the repository's plugins contributed, loaded during `create` because the workspace
17
+ * providers they bring are needed before any package can be found. `cli.ts` registers them. */
18
+ pluginCommands = [];
19
+ /**
20
+ * Cached repository scope - see `_repositoryScope`.
21
+ *
22
+ * **Non-enumerable**, and for the same reason `targetVersion` itself is: this cache holds a
23
+ * `PackageScope` per package, each carrying that throwing getter, and it hangs off a `Repository`
24
+ * which every `Package` points back at. Left enumerable, *any* deep walk of a package reached it
25
+ * and threw - measured through a test's own `toEqual` diff, but a `JSON.stringify` or a debugger
26
+ * would do it too, with an error about `version` that has nothing to do with what the caller did.
27
+ * An internal cache has no business being walked anyway.
28
+ */
29
+ _repoScope;
30
+ /** Cached `${{ git.* }}` facts - see `_gitScope`. Non-enumerable for the same reason as above,
31
+ * and because reading it is a subprocess: a deep walk of a package must not spawn one. */
32
+ _git;
33
+ /**
34
+ * Files `${{ read(...) }}` has parsed, shared by every package's scope and keyed by the identity
35
+ * of the bytes - see `readStructuredFile`.
36
+ *
37
+ * **On the repository rather than per scope, and that is the whole point of it**: `configScope`
38
+ * is built once per package, so a cache living there would re-read a repository-level file once
39
+ * for every package that mentions it.
40
+ */
41
+ _readCache = new Map();
15
42
  constructor(dirname, monorepo, packages,
16
43
  /** The directory `Repository.create()` was actually invoked from - unlike `dirname` (the
17
44
  * resolved repository root, possibly several levels up), this is where the user's shell
@@ -54,7 +81,7 @@ export class Repository extends Package {
54
81
  result = result.filter(p => scopes.includes(p.name));
55
82
  }
56
83
  if (options?.toposort)
57
- topoSortPackages(result);
84
+ this._topoSortPackages(result);
58
85
  return result;
59
86
  }
60
87
  getPackage(name) {
@@ -88,6 +115,53 @@ export class Repository extends Package {
88
115
  }
89
116
  return result;
90
117
  }
118
+ /**
119
+ * The scope a `${{ ... }}` expression is evaluated against for `pkg`, optionally with the version
120
+ * a run is about to write bound into it.
121
+ *
122
+ * `version` is the only caller that passes one, and it has to: the config was resolved before its
123
+ * plan existed, so `pkg.targetVersion` had nothing to be. Re-evaluating that raw value against
124
+ * this is how that one binding gets filled in, without every other command paying for it - or
125
+ * seeing a value that means nothing to them.
126
+ */
127
+ configScope(pkg, options) {
128
+ const _this = this;
129
+ return {
130
+ pkg: this._packageScope(pkg, options?.targetVersion),
131
+ repository: this._repositoryScope(),
132
+ /** `pkg.dirname`, not the repository root: a `"[*]"` block asking whether
133
+ * `tsconfig-build.json` exists has to be answered per package. */
134
+ file: createFileScope(pkg.dirname),
135
+ /** Same base directory as `file`, so one `"[*]"` declaration reads each package's own copy -
136
+ * and the cache is the repository's, so a file they *share* is parsed once. */
137
+ read: createReadScope(pkg.dirname, this._readCache),
138
+ env: { ...process.env },
139
+ semver,
140
+ path,
141
+ /**
142
+ * A getter, and cached on the **repository** rather than in this closure: `configScope` is
143
+ * called once per package, so a per-scope cache would still shell out once per package in a
144
+ * monorepo that mentions git at all.
145
+ *
146
+ * Enumerable, unlike `pkg.targetVersion` - it has a real answer everywhere, so nothing needs
147
+ * hiding. What keeps it lazy is `interpolateConfig` building its context from property
148
+ * descriptors instead of spreading; see the note there.
149
+ */
150
+ get git() {
151
+ return _this._gitScope();
152
+ },
153
+ };
154
+ }
155
+ /** `${{ git.* }}`, read at most once per repository per process. */
156
+ _gitScope() {
157
+ if (this._git)
158
+ return this._git;
159
+ const built = this._readGitScope(this.dirname);
160
+ /** Defined rather than assigned, so the cache stays out of every enumeration of the repository
161
+ * - the same reason `_repoScope` is defined this way. */
162
+ Object.defineProperty(this, '_git', { value: built, enumerable: false, writable: true });
163
+ return built;
164
+ }
91
165
  /**
92
166
  * Resolves the effective rman config for the repository root and every package, cascading
93
167
  * root -> intermediate directories -> package directory, so a `.rmanrc` placed anywhere along
@@ -98,77 +172,165 @@ export class Repository extends Package {
98
172
  * single-package repository it *is* the one package, so `"[*]"` has to reach it; in a monorepo
99
173
  * nothing under `getPackages()` is the root, so only its own unmarked config applies.
100
174
  */
175
+ /**
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.
178
+ *
179
+ * A repository's own `repository` is itself, which reads oddly and is the honest answer:
180
+ * `Repository extends Package`, so the repository *is* a package of its own repository.
181
+ */
182
+ _linkPackages() {
183
+ this.repository = this;
184
+ this.rootPackage.repository = this;
185
+ 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;
198
+ }
199
+ pkg.parent = pkg === this.rootPackage ? undefined : parent;
200
+ }
201
+ }
101
202
  async _resolveConfigs() {
102
203
  const cache = new Map();
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
- };
204
+ /**
205
+ * `DEFERRED_PATHS` are left *raw* in the result (see `interpolateConfig`'s `walk`), so the
206
+ * resolved config is the only copy anyone needs - `version` reads its own hooks straight off
207
+ * `config` and interpolates them itself once `pkg.targetVersion` exists. There used to be a
208
+ * second `rawConfig` copy of every package's config for that one reader; measured identical.
209
+ */
210
+ /**
211
+ * The root is resolved **with its name**, like every other package, because selectors now speak
212
+ * to it: `"[/]"` names it and `"[*]"` includes it. It used to be resolved without one, which is
213
+ * what made `"[*]"` quietly mean "the workspace packages" - `"[ws:*]"` is that, spelled.
214
+ */
215
+ const rootRaw = await resolveConfig(this.dirname, this.dirname, cache, this.rootPackage.name);
216
+ this.config = interpolateConfig(rootRaw, this.configScope(this.rootPackage), { skip: DEFERRED_PATHS });
217
+ for (const pkg of this.packages) {
218
+ const raw = await resolveConfig(this.dirname, pkg.dirname, cache, pkg.name);
219
+ pkg.config = interpolateConfig(raw, this.configScope(pkg), { skip: DEFERRED_PATHS });
220
+ }
221
+ if (this.monorepo)
222
+ this.rootPackage.config = this.config;
223
+ }
224
+ _topoSortPackages(packages) {
225
+ packages.sort((a, b) => {
226
+ if (b.dependencies.includes(a))
227
+ return -1;
228
+ if (a.dependencies.includes(b))
229
+ return 1;
230
+ return 0;
231
+ });
232
+ }
233
+ _packageScope(pkg, targetVersion) {
234
+ // A manifest without a "name" is unusual but legal, and `info` prints such a package rather
235
+ // than refusing it - so the scope has to survive one too.
236
+ const name = pkg.name ?? '';
237
+ /** Splitting `@scope/name` is npm's convention, not a universal - the provider decides. */
238
+ const { scope: nameScope, unscopedName } = Manifest.splitName(pkg.dirname, name);
239
+ const scope = {
240
+ name,
241
+ scope: nameScope,
242
+ unscopedName,
243
+ version: pkg.version ?? '',
244
+ basename: path.basename(pkg.dirname),
245
+ dirname: pkg.dirname,
246
+ relativeDir: path.relative(this.dirname, pkg.dirname),
247
+ /** The same `Package.provider`, so a `"[*]"` declaration can say something for one ecosystem
248
+ * only - `if: "${{ pkg.provider === 'node' }}"` in a polyglot repository. Omitting it would
249
+ * leave the expression scope and the `Package` disagreeing about what a package is. */
250
+ provider: pkg.provider,
251
+ // A copy: an expression has no business mutating the package rman is about to act on.
252
+ manifest: { ...pkg.manifest.raw },
120
253
  };
121
- const packageScopes = this.packages.map(scopeOf);
122
- let gitScope;
123
- const repository = {
124
- ...scopeOf(this.rootPackage),
254
+ if (targetVersion !== undefined) {
255
+ scope.targetVersion = targetVersion;
256
+ return scope;
257
+ }
258
+ /** Otherwise a getter that *throws*, rather than a missing key handing back `undefined` and
259
+ * letting a tag come out as "app:undefined". Defined rather than assigned because there is no
260
+ * value to assign - outside a `version` run there is no target version to name.
261
+ *
262
+ * Non-enumerable, and that is load-bearing: spreading an object runs its enumerable getters,
263
+ * so an enumerable one threw the moment `_repositoryScope` spread the root's scope - which is
264
+ * every `configScope()` call, for every command. Property access still triggers it, which is
265
+ * the only thing it exists for. */
266
+ Object.defineProperty(scope, 'targetVersion', {
267
+ enumerable: false,
268
+ get() {
269
+ throw new Error('pkg.targetVersion is only available while "version" is running - no other command has a target version');
270
+ },
271
+ });
272
+ return scope;
273
+ }
274
+ /** Built once and reused: it is the same for every package, and its `git` getter caches too, so a
275
+ * repository whose config never mentions git spawns none. */
276
+ _repositoryScope() {
277
+ if (this._repoScope)
278
+ return this._repoScope;
279
+ const packageScopes = this.packages.map(p => this._packageScope(p));
280
+ const built = {
281
+ ...this._packageScope(this.rootPackage),
125
282
  monorepo: this.monorepo,
126
283
  packages: packageScopes,
127
284
  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
285
  };
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));
136
- for (const pkg of this.packages) {
137
- pkg.config = withVars(pkg, await resolveConfig(this.dirname, pkg.dirname, cache, pkg.name));
138
- }
139
- if (this.monorepo)
140
- this.rootPackage.config = this.config;
286
+ /** Defined rather than assigned, so the cache stays out of every enumeration of this object -
287
+ * see the field's own doc for what walked into it. */
288
+ Object.defineProperty(this, '_repoScope', { value: built, enumerable: false, writable: true });
289
+ return built;
290
+ }
291
+ /**
292
+ * One `.rmanrc "dependencies"` entry to a package: its **name** first, then a
293
+ * **repository-relative directory**.
294
+ *
295
+ * The path form is what makes the key usable outside npm. A name identifies a package only where
296
+ * the ecosystem guarantees uniqueness - the same reason `Package.dependencies` holds references
297
+ * and `Workspace.Layout` carries paths - so a repository whose names collide, or whose packages
298
+ * have no names rman can read, states the edge by directory instead.
299
+ *
300
+ * Name first because that is what a Node repository writes and there is no ambiguity in practice:
301
+ * a package name that is also an existing directory path in the same repository does not occur.
302
+ * An entry matching neither is ignored, as an unknown name always was - the graph is a statement
303
+ * about packages that exist.
304
+ */
305
+ _resolveDeclaredPackage(entry) {
306
+ const byName = this.getPackage(entry);
307
+ if (byName)
308
+ return byName;
309
+ const dir = path.resolve(this.dirname, entry);
310
+ return this.packages.find(p => path.resolve(p.dirname) === dir);
141
311
  }
142
312
  _updateDependencies() {
143
313
  const deps = {};
144
314
  for (const pkg of this.packages) {
145
- const o = {
146
- ...pkg.json.dependencies,
147
- ...pkg.json.devDependencies,
148
- ...pkg.json.peerDependencies,
149
- ...pkg.json.optionalDependencies,
150
- };
151
- const configDeps = pkg.config.dependencies;
152
- if (configDeps) {
153
- if (Array.isArray(configDeps))
154
- configDeps.forEach(x => (o[x] = o[x] || '*'));
155
- else
156
- Object.assign(o, configDeps);
315
+ /** Which manifest fields hold dependencies is the ecosystem's business, so the provider
316
+ * reads them - npm's four field names used to be spelled out right here. */
317
+ const dependencies = [...Manifest.dependenciesOf(pkg, this.packages)];
318
+ /** `.rmanrc "dependencies"` on top, and it works with no provider at all: a repository rman
319
+ * cannot read the manifests of can still declare its graph by hand. */
320
+ const declared = pkg.config.dependencies ?? [];
321
+ for (const entry of declared) {
322
+ const p = this._resolveDeclaredPackage(entry);
323
+ if (p && p !== pkg && !dependencies.includes(p))
324
+ dependencies.push(p);
157
325
  }
158
- const dependencies = [];
159
- for (const k of Object.keys(o)) {
160
- const p = this.getPackage(k);
161
- if (p)
162
- dependencies.push(k);
163
- }
164
- deps[pkg.name] = dependencies;
326
+ deps[pkg.name] = dependencies.map(d => d.name);
165
327
  pkg.dependencies = dependencies;
166
328
  }
167
329
  let circularCheck;
168
330
  const deepFindDependencies = (pkg, target) => {
169
- if (circularCheck.includes(pkg.name))
331
+ if (circularCheck.includes(pkg))
170
332
  return;
171
- circularCheck.push(pkg.name);
333
+ circularCheck.push(pkg);
172
334
  for (const s of pkg.dependencies) {
173
335
  /** `target` starts out *as* the top-level package's own `dependencies` array, so its
174
336
  * direct entries are trivially "already in target" - recursing only when newly-added
@@ -177,9 +339,7 @@ export class Repository extends Package {
177
339
  * and never let the top-level package end up depending on itself via a cycle. */
178
340
  if (s !== circularCheck[0] && !target.includes(s))
179
341
  target.push(s);
180
- const p = this.getPackage(s);
181
- if (p)
182
- deepFindDependencies(p, target);
342
+ deepFindDependencies(s, target);
183
343
  }
184
344
  };
185
345
  for (const pkg of this.packages) {
@@ -187,25 +347,63 @@ export class Repository extends Package {
187
347
  deepFindDependencies(pkg, pkg.dependencies);
188
348
  }
189
349
  }
190
- static async create(root, options) {
191
- const dirname = root || process.cwd();
192
- let deep = options?.deep ?? 10;
193
- let pkgDirname = dirname;
194
- while (deep-- >= 0 && fs.existsSync(pkgDirname)) {
195
- const f = path.join(pkgDirname, 'package.json');
196
- if (fs.existsSync(f)) {
197
- const pkgJson = JSON.parse(fs.readFileSync(f, 'utf-8'));
198
- if (Array.isArray(pkgJson.workspaces)) {
199
- const packages = this._resolvePackages(pkgDirname, pkgJson.workspaces);
200
- return Repository._init(new Repository(pkgDirname, true, packages, dirname));
201
- }
202
- /** If we reach to the root of the project */
203
- if (fs.existsSync(path.join(pkgDirname, '.git')))
204
- break;
350
+ /** `git` facts for a `${{ git.* }}` expression. Synchronous on purpose: it backs a lazy
351
+ * getter, and a getter cannot await. Everything is `undefined` outside a git checkout - not an
352
+ * error, just a repository without one. */
353
+ _readGitScope(dirname) {
354
+ const run = (args) => {
355
+ try {
356
+ return execFileSync('git', args, { cwd: dirname, stdio: ['ignore', 'pipe', 'ignore'] })
357
+ .toString()
358
+ .trim();
205
359
  }
206
- pkgDirname = path.resolve(pkgDirname, '..');
207
- }
208
- return Repository._init(new Repository(dirname, false, [], dirname));
360
+ catch {
361
+ return undefined;
362
+ }
363
+ };
364
+ const sha = run(['rev-parse', 'HEAD']);
365
+ if (sha === undefined)
366
+ return { branch: undefined, sha: undefined, shortSha: undefined, dirty: undefined };
367
+ const status = run(['status', '--porcelain']);
368
+ return {
369
+ // Empty on a detached HEAD, which is what a CI checkout often is - reported as undefined
370
+ // rather than an empty string, so `?? 'detached'` in an expression works.
371
+ branch: run(['branch', '--show-current']) || undefined,
372
+ sha,
373
+ shortSha: sha.slice(0, 7),
374
+ dirty: status === undefined ? undefined : status.length > 0,
375
+ };
376
+ }
377
+ /**
378
+ * Opens the repository containing `root` (default: the current directory).
379
+ *
380
+ * Three steps, in this order because each needs the one before it:
381
+ *
382
+ * 1. **Find the root** without knowing any ecosystem - see `Workspace.findRoot`. It cannot be
383
+ * otherwise: the plugins that know what a package is are named in the config file this step
384
+ * is looking for.
385
+ * 2. **Load the plugins** the root's config names, which registers their workspace providers
386
+ * (and their commands, handed on via `pluginCommands` - `cli.ts` registers those).
387
+ * 3. **Ask the providers** for the layout. None recognizing it means a repository that is itself
388
+ * the one package.
389
+ *
390
+ * **A repository whose `.rmanrc` names no plugin has no packages beyond itself**, and that is the
391
+ * boundary working rather than failing: `workspaces` in a `package.json` is npm's idea, so it
392
+ * takes `plugins: ['rman-node']` to be read as one.
393
+ */
394
+ static async create(root, options) {
395
+ const from = root || process.cwd();
396
+ const rootDir = Workspace.findRoot(from, options?.deep ?? 10);
397
+ /** The root's own config, raw: `plugins` is a list of package names, so it needs neither the
398
+ * package list (which does not exist yet) nor expression interpolation. */
399
+ const rootConfig = await readDirConfig(rootDir);
400
+ const pluginCommands = await loadPlugins(rootDir, rootConfig);
401
+ const layout = Workspace.resolve(rootDir);
402
+ const packages = (layout?.packageDirs ?? []).map(dir => new Package(dir));
403
+ const repo = new Repository(layout?.root ?? rootDir, packages.length > 0, packages, from);
404
+ repo.pluginCommands = pluginCommands;
405
+ repo._linkPackages();
406
+ return Repository._init(repo);
209
407
  }
210
408
  /** Finishes constructing `repo` with the async work a constructor can't do itself - resolving
211
409
  * `.rmanrc`/`.rmanrc.yml`/`.rmanrc.cjs`/`.mjs`/`.js` config (which may need a dynamic `import()`)
@@ -215,57 +413,4 @@ export class Repository extends Package {
215
413
  repo._updateDependencies();
216
414
  return repo;
217
415
  }
218
- static _resolvePackages(dirname, patterns) {
219
- const packages = [];
220
- for (const pattern of patterns) {
221
- const dirs = glob.sync(pattern, {
222
- cwd: dirname,
223
- absolute: true,
224
- deep: 0,
225
- onlyDirectories: true,
226
- });
227
- for (const dir of dirs) {
228
- const f = path.join(dir, 'package.json');
229
- if (fs.existsSync(f))
230
- packages.push(new Package(dir));
231
- }
232
- }
233
- return packages;
234
- }
235
- }
236
- function topoSortPackages(packages) {
237
- packages.sort((a, b) => {
238
- if (b.dependencies.includes(a.name))
239
- return -1;
240
- if (a.dependencies.includes(b.name))
241
- return 1;
242
- return 0;
243
- });
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
416
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolves a config-supplied module reference - an `extends` base, a `plugins` entry - **relative
3
+ * to the file that named it**, not to rman's own location.
4
+ *
5
+ * That distinction is the whole reason this is a function: resolving from rman would look in rman's
6
+ * own dependencies, where a repository's shared config or plugin has no reason to be. A bare
7
+ * specifier therefore goes through `from`'s own `node_modules` (a package's `exports` subpaths
8
+ * included); anything path-like resolves against `from`'s directory.
9
+ *
10
+ * `label` names the config key in the error, so a failure says which setting to go and look at.
11
+ */
12
+ export declare function resolveConfigTarget(target: string, from: string, label: string): string;
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ /** File forms a path-like target may take when it names no extension of its own. */
6
+ const TARGET_EXTENSIONS = ['', '.yml', '.yaml', '.json', '.cjs', '.mjs', '.js'];
7
+ /**
8
+ * Resolves a config-supplied module reference - an `extends` base, a `plugins` entry - **relative
9
+ * to the file that named it**, not to rman's own location.
10
+ *
11
+ * That distinction is the whole reason this is a function: resolving from rman would look in rman's
12
+ * own dependencies, where a repository's shared config or plugin has no reason to be. A bare
13
+ * specifier therefore goes through `from`'s own `node_modules` (a package's `exports` subpaths
14
+ * included); anything path-like resolves against `from`'s directory.
15
+ *
16
+ * `label` names the config key in the error, so a failure says which setting to go and look at.
17
+ */
18
+ export function resolveConfigTarget(target, from, label) {
19
+ const dir = path.dirname(path.resolve(from));
20
+ if (target.startsWith('.') || path.isAbsolute(target)) {
21
+ const candidate = path.resolve(dir, target);
22
+ const found = TARGET_EXTENSIONS.map(ext => candidate + ext).find(f => fs.existsSync(f) && fs.statSync(f).isFile());
23
+ if (!found)
24
+ throw new Error(`"${label}" target "${target}" was not found, resolved from "${from}"`);
25
+ return found;
26
+ }
27
+ try {
28
+ return createRequire(pathToFileURL(path.join(dir, 'noop.js'))).resolve(target);
29
+ }
30
+ catch {
31
+ throw new Error(`"${label}" target "${target}" could not be resolved from "${from}" - is it installed in this repository?`);
32
+ }
33
+ }
@@ -0,0 +1,75 @@
1
+ import type { Logger } from '../utils/logger.js';
2
+ import type { RunBinOptions, RunBinResult } from '../utils/run-bin.js';
3
+ import type { Package } from './package.js';
4
+ import type { Repository } from './repository.js';
5
+ /**
6
+ * What a **function step** is handed - a `run.<script>.before`/`.exec`/`.after` or a
7
+ * `version.<slot>` written as JavaScript instead of a shell command, and the `if` deciding whether
8
+ * a script runs at all.
9
+ *
10
+ * An object rather than loose parameters, for the reason `CommandContext` is one: a member added
11
+ * later must not break every step already written against it.
12
+ *
13
+ * **The whole point of the function form is *when* it runs.** A `${{ }}` expression is evaluated
14
+ * while the repository's config resolves - which every command does, `rman list` included - so it
15
+ * can only ever answer questions about the state the config was loaded in, and anything it *did*
16
+ * would happen on every invocation. A function step runs when its turn comes, with the real
17
+ * objects, in the package's own directory. Reach for it exactly when that difference matters;
18
+ * a shell command is still the right shape for a shell command.
19
+ */
20
+ export interface RunStepContext {
21
+ /**
22
+ * The package this step is running for - `pkg`, matching `${{ pkg }}` in an expression rather
23
+ * than `CommandContext.package`. `package` is a reserved word, so that spelling forces every
24
+ * author to rename it while destructuring (`{ package: current }`), which is friction paid at
25
+ * every call site for no benefit.
26
+ *
27
+ * Always set, unlike `CommandContext.package`: a step belongs to a package by construction, and
28
+ * the repo-wide bookend belongs to the root package.
29
+ */
30
+ pkg: Package;
31
+ repository: Repository;
32
+ /**
33
+ * The directory this step is *about* - the package's own, or the repository root for a monorepo's
34
+ * bookend. The same directory a shell step in this slot is spawned in.
35
+ *
36
+ * **`process.cwd()` is NOT changed, and cannot be.** A shell step gets a real working directory
37
+ * because it is a child process; a function step runs inside rman's own, and `run` executes
38
+ * packages **concurrently** - one step calling `process.chdir()` would move the ground under
39
+ * every other step running at that moment. So a relative path resolves against wherever rman was
40
+ * invoked, which is almost never what the step meant:
41
+ *
42
+ * ```js
43
+ * fs.writeFileSync('out.txt', data) // the repository root. Measured, and wrong.
44
+ * fs.writeFileSync(path.join(ctx.cwd, 'out.txt'), data) // the package
45
+ * ```
46
+ *
47
+ * `ctx.runBin` is already bound to this directory, so a binary run through it needs no such care.
48
+ */
49
+ cwd: string;
50
+ /**
51
+ * The repository's locally installed binaries, already carrying this run's `cwd` and log level -
52
+ * handed over rather than imported, for the reason `CommandContext.runBin` is.
53
+ */
54
+ runBin: (bin: string, argv: string[], options?: RunBinOptions) => Promise<RunBinResult>;
55
+ /** Logger at this run's resolved level. **Prefer it to `console`**: with the live progress panel
56
+ * on, a direct write lands beside the panel rather than in the step's own log. */
57
+ logger: Logger;
58
+ }
59
+ /**
60
+ * A step written as JavaScript. **Failure is a throw** - the return value means nothing, exactly as
61
+ * a non-zero exit is what fails a shell step and what `runBin` rejects on. A step that can report
62
+ * trouble only by returning something nobody reads is a step that passes while doing nothing.
63
+ */
64
+ export type RunStepFn = (context: RunStepContext) => void | Promise<void>;
65
+ /** One entry of a `before`/`exec`/`after` slot: a shell command, or a function. A list of them runs
66
+ * in sequence, and the two forms mix freely within one list. */
67
+ export type RunStepValue = string | RunStepFn;
68
+ /**
69
+ * A `run.<script>.if` written as JavaScript, deciding whether the script runs for this package.
70
+ *
71
+ * The string form is a small closed grammar (`changed and not private`) which cannot express an
72
+ * arbitrary condition, and a `${{ }}` one is frozen at config-load time. This is evaluated per
73
+ * package, when the run reaches it - the same context a step gets.
74
+ */
75
+ export type RunConditionFn = (context: RunStepContext) => boolean | Promise<boolean>;
@@ -0,0 +1 @@
1
+ export {};