rman 1.0.12 → 1.1.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 (98) hide show
  1. package/README.md +90 -70
  2. package/cli.js +225 -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 +60 -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 +142 -13
  17. package/core/config.js +266 -43
  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 +57 -0
  25. package/core/merge-config.js +146 -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 +77 -1
  31. package/core/repository.js +242 -129
  32. package/core/resolve-target.d.ts +12 -0
  33. package/core/resolve-target.js +33 -0
  34. package/core/version-scheme.d.ts +134 -0
  35. package/core/version-scheme.js +148 -0
  36. package/core/workspace.d.ts +68 -0
  37. package/core/workspace.js +83 -0
  38. package/index.d.ts +54 -8
  39. package/index.js +42 -7
  40. package/interfaces/rman-config.interface.d.ts +171 -36
  41. package/package.json +15 -7
  42. package/services/change-hash.service.d.ts +88 -0
  43. package/services/change-hash.service.js +112 -0
  44. package/services/changelog.service.d.ts +8 -13
  45. package/services/changelog.service.js +12 -11
  46. package/services/conventional-commits.service.d.ts +73 -0
  47. package/services/conventional-commits.service.js +116 -0
  48. package/services/docker-publish.service.js +1 -1
  49. package/services/exec.service.js +1 -1
  50. package/services/github-release.service.d.ts +2 -2
  51. package/services/github-release.service.js +10 -5
  52. package/services/list.service.js +5 -2
  53. package/services/run.service.d.ts +60 -0
  54. package/services/run.service.js +109 -65
  55. package/services/system-info.d.ts +22 -7
  56. package/services/system-info.js +8 -23
  57. package/services/version-plan.service.d.ts +244 -0
  58. package/services/version-plan.service.js +414 -0
  59. package/services/version.service.d.ts +92 -82
  60. package/services/version.service.js +219 -433
  61. package/services.d.ts +5 -3
  62. package/services.js +5 -3
  63. package/utils/bin-path.d.ts +59 -0
  64. package/utils/bin-path.js +82 -0
  65. package/utils/child-tracker.d.ts +16 -0
  66. package/utils/child-tracker.js +30 -0
  67. package/utils/exec.d.ts +13 -2
  68. package/utils/exec.js +17 -17
  69. package/utils/git.d.ts +9 -3
  70. package/utils/git.js +10 -2
  71. package/utils/package-filter.d.ts +33 -2
  72. package/utils/package-filter.js +47 -7
  73. package/utils/release-version.js +3 -3
  74. package/utils/run-bin.d.ts +46 -0
  75. package/utils/run-bin.js +63 -0
  76. package/utils/version-stamp.d.ts +14 -6
  77. package/utils/version-stamp.js +25 -13
  78. package/commands/ci.command.js +0 -30
  79. package/commands/clean.command.d.ts +0 -3
  80. package/commands/clean.command.js +0 -36
  81. package/commands/publish.command.d.ts +0 -3
  82. package/commands/publish.command.js +0 -225
  83. package/rmanrc.schema.json +0 -392
  84. package/services/ci.service.d.ts +0 -40
  85. package/services/ci.service.js +0 -204
  86. package/services/clean.service.d.ts +0 -42
  87. package/services/clean.service.js +0 -226
  88. package/services/publish.service.d.ts +0 -79
  89. package/services/publish.service.js +0 -273
  90. package/utils/change-hash.d.ts +0 -68
  91. package/utils/change-hash.js +0 -98
  92. package/utils/conventional-commits.d.ts +0 -52
  93. package/utils/conventional-commits.js +0 -90
  94. package/utils/npm-run-path.d.ts +0 -67
  95. package/utils/npm-run-path.js +0 -63
  96. package/utils/workspace-range.d.ts +0 -17
  97. package/utils/workspace-range.js +0 -28
  98. /package/commands/{ci.command.d.ts → config.command.d.ts} +0 -0
@@ -0,0 +1,146 @@
1
+ /** The prefix that turns a key into an append instead of a replacement: `+before` adds to whatever
2
+ * `before` already resolved to, rather than taking its place. */
3
+ export const APPEND_PREFIX = '+';
4
+ /**
5
+ * Keys that **append whether or not you ask** - `+plugins` is accepted and means nothing extra.
6
+ *
7
+ * `plugins` is the whole list because it is the one key where replacing is never what anyone meant:
8
+ * every other setting has a value a closer layer can sensibly overrule, while a plugin *adds
9
+ * commands and seams*, and a repository naming one has no wish to lose the ones its shared config
10
+ * brought. Replacing was the silent failure - `extends`-ing a toolchain config and then adding a
11
+ * plugin of your own dropped the toolchain's, and what you noticed was `Unknown argument: publish`.
12
+ *
13
+ * Do not extend this list casually: a key that always appends can never be *un*-said by a closer
14
+ * layer, which is only acceptable where the value is a set of contributions rather than a decision.
15
+ */
16
+ export const ALWAYS_APPEND = ['plugins'];
17
+ /** `"+before"` -> `"before"`, or `undefined` for a key that isn't an append. */
18
+ export function appendTarget(key) {
19
+ return key.length > APPEND_PREFIX.length && key.startsWith(APPEND_PREFIX)
20
+ ? key.slice(APPEND_PREFIX.length)
21
+ : undefined;
22
+ }
23
+ /**
24
+ * Merges `source` onto `target` in place, the way every layer of rman config is combined - a
25
+ * directory's own file forms, the directory chain, `"[selector]"` blocks, and an `extends` base.
26
+ * One implementation for all of them, so they cannot disagree about what an append means.
27
+ *
28
+ * Plain keys replace, and nested objects merge recursively, as a deep merge always has. What this
29
+ * adds is `+key`:
30
+ *
31
+ * ```yaml
32
+ * # the root says before: "rm ./build"
33
+ * # a package adds +before: "rm ./cache"
34
+ * # it resolves to before: ["rm ./build", "rm ./cache"]
35
+ * ```
36
+ *
37
+ * Appending is the half a shared config can't live without: a base that declares
38
+ * `before: ["rm ./build"]` otherwise forces every repository wanting one more step to restate the
39
+ * whole list - which is a copy of the base, silently frozen at the version it was copied from.
40
+ *
41
+ * A scalar is promoted to a list on the way, so neither side has to be written as an array. With
42
+ * nothing inherited, `+key` simply produces a one-element list.
43
+ *
44
+ * On a value that isn't a list, the prefix is **ignored** rather than an error, because there the
45
+ * two spellings already mean the same thing: objects merge whether or not you asked them to, and a
46
+ * scalar has nothing to append to, so `+key` behaves exactly as `key` would. Nothing is silently
47
+ * dropped - the value still lands.
48
+ *
49
+ * `key` and `+key` in the same object are both honored, in that order: the replacement happens
50
+ * first, then the append lands on top of it.
51
+ */
52
+ export function mergeConfig(target, source) {
53
+ // Plain keys first, so a `+key` alongside its own `key` appends to that replacement rather than
54
+ // to whatever the previous layer had.
55
+ for (const [key, value] of Object.entries(source)) {
56
+ if (appendTarget(key))
57
+ continue;
58
+ /** `plugins`: additive at every layer, so the closer one adds rather than takes over. */
59
+ if (ALWAYS_APPEND.includes(key)) {
60
+ appendList(target, key, value);
61
+ continue;
62
+ }
63
+ assignMerged(target, key, value);
64
+ }
65
+ for (const [key, value] of Object.entries(source)) {
66
+ const plain = appendTarget(key);
67
+ if (!plain)
68
+ continue;
69
+ // An object merges either way, so the prefix asks for nothing extra - resolve it now and let
70
+ // the two spellings coincide.
71
+ if (isPlainObject(value) || isPlainObject(target[plain])) {
72
+ assignMerged(target, plain, value);
73
+ continue;
74
+ }
75
+ if (plain in target) {
76
+ appendList(target, plain, value);
77
+ continue;
78
+ }
79
+ /** Nothing to append to *yet*. Kept as an append rather than collapsed into the plain key,
80
+ * because the layer that provides it may still be coming: a directory's own file forms are
81
+ * merged into an empty object long before the selector blocks and parent directories they
82
+ * append to are. Collapsing here lost both of those - measured. `finalizeConfig` turns
83
+ * whatever is still outstanding at the end into a plain list. */
84
+ target[key] = [...toList(target[key] ?? []), ...toList(value)];
85
+ }
86
+ return target;
87
+ }
88
+ /**
89
+ * Turns any `+key` still outstanding into its plain key, as a list - the "nothing was inherited"
90
+ * case, where an append simply is the whole value.
91
+ *
92
+ * Called once on a fully resolved config, and only there: until then an outstanding append may
93
+ * still find the layer it belongs to, and a config handed to a command must carry no `+key` at all
94
+ * or every reader would have to know about them.
95
+ */
96
+ export function finalizeConfig(config) {
97
+ if (Array.isArray(config))
98
+ return config.map(finalizeConfig);
99
+ if (!isPlainObject(config))
100
+ return config;
101
+ const result = {};
102
+ for (const [key, value] of Object.entries(config)) {
103
+ const plain = appendTarget(key);
104
+ if (!plain) {
105
+ result[key] = finalizeConfig(value);
106
+ continue;
107
+ }
108
+ const pending = finalizeConfig(value);
109
+ result[plain] = plain in result ? [...toList(result[plain]), ...toList(pending)] : toList(pending);
110
+ }
111
+ return result;
112
+ }
113
+ function assignMerged(target, key, value) {
114
+ if (isPlainObject(value)) {
115
+ if (!isPlainObject(target[key]))
116
+ target[key] = {};
117
+ mergeConfig(target[key], value);
118
+ return;
119
+ }
120
+ target[key] = Array.isArray(value) ? [...value] : value;
121
+ }
122
+ /**
123
+ * Appends `value` to `target[key]`, de-duplicating **only** an `ALWAYS_APPEND` key.
124
+ *
125
+ * That asymmetry is the point. `plugins` appends without being asked, so a repository and the config
126
+ * it extends both naming `'rman-node'` is the ordinary case rather than a mistake, and the list is
127
+ * also what `rman config` prints. An explicit `+before`, by contrast, was *written* - repeating a
128
+ * step that is already there is a strange thing to ask for, but it is what was asked for, and
129
+ * silently collapsing it would make one layer's list depend on another's contents.
130
+ *
131
+ * By identity (`===`), which covers a string by value and a plugin object by reference; two
132
+ * *different* objects claiming one plugin name are caught where it counts, at registration - see
133
+ * `loadPlugins`.
134
+ */
135
+ function appendList(target, key, value) {
136
+ const merged = [...toList(target[key] ?? []), ...toList(value)];
137
+ target[key] = ALWAYS_APPEND.includes(key) ? merged.filter((entry, at) => merged.indexOf(entry) === at) : merged;
138
+ }
139
+ function toList(value) {
140
+ return Array.isArray(value) ? value : [value];
141
+ }
142
+ /** A config object, as opposed to an array or anything with its own prototype - only the former
143
+ * merges key by key. */
144
+ function isPlainObject(value) {
145
+ return !!value && typeof value === 'object' && !Array.isArray(value);
146
+ }
package/core/package.d.ts CHANGED
@@ -1,17 +1,83 @@
1
1
  import type { RmanConfig } from '../interfaces/rman-config.interface.js';
2
+ import { Manifest } from './manifest.js';
3
+ import type { Repository } from './repository.js';
4
+ import { type VersionScheme } from './version-scheme.js';
2
5
  export declare class Package {
3
6
  readonly dirname: string;
4
- private _json;
5
- dependencies: string[];
6
- /** Effective rman config for this package, cascaded from the repository root. */
7
+ /**
8
+ * This package's identity, read through whichever `ManifestProvider` recognizes its directory.
9
+ *
10
+ * There is no `json` here any more, and that is the point: `package.json` is npm's answer to
11
+ * "where is a package's name and version written", not rman's. `manifest.raw` is still the whole
12
+ * document for a command that knows its own ecosystem - `rman-node` reads `scripts` and
13
+ * `publishConfig` off it - but the core only ever touches `name`, `version` and `private`.
14
+ */
15
+ manifest: Manifest;
16
+ /**
17
+ * In-repo packages this one depends on - **the full transitive closure**, not just its direct
18
+ * dependencies (see `Repository._updateDependencies`).
19
+ *
20
+ * References, not names: a name is only an identifier if the ecosystem guarantees uniqueness,
21
+ * which npm does and others do not - Go identifies a module by import path, and nothing stops a
22
+ * repository from holding two packages whose short names collide. A `Package` is unambiguous
23
+ * whatever the ecosystem, and comparing by identity removes a lookup from every consumer.
24
+ */
25
+ dependencies: Package[];
26
+ /** Effective rman config for this package, cascaded from the repository root, with every
27
+ * `${{ ... }}` expression already evaluated. */
7
28
  config: RmanConfig;
29
+ /**
30
+ * The repository this package belongs to - so anything holding a package can reach the whole
31
+ * picture (its siblings, the root's config, git) without being handed it separately.
32
+ *
33
+ * Assigned by `Repository.create` rather than taken as a constructor argument, and it has to be:
34
+ * `Repository extends Package`, so a repository constructing itself runs this constructor before
35
+ * it exists to be passed in. A repository's own is itself.
36
+ */
37
+ repository: Repository;
38
+ /**
39
+ * The package whose directory contains this one - the repository root for an ordinary member, and
40
+ * a genuine enclosing package for one nested inside another (which `Repository.currentPackage`
41
+ * already has to reason about). `undefined` for the root itself, which nothing contains.
42
+ */
43
+ parent?: Package;
44
+ /**
45
+ * How this package's versions are numbered - `pkg.versionScheme.next(pkg.version, 'minor')`.
46
+ *
47
+ * Comes from the same provider that read the manifest, because the ecosystem that decides where
48
+ * a version is written is the one that decides how it is numbered - a `pyproject.toml` read with
49
+ * semver's arithmetic would be a pair that never occurs in reality.
50
+ *
51
+ * Defaults to semver, so this seam existing changes nothing. **A group must not mix schemes** -
52
+ * a group is one version line, and comparing across schemes is meaningless; `assertOneScheme`
53
+ * refuses it rather than acting on whatever falls out.
54
+ */
55
+ versionScheme: VersionScheme;
56
+ /** The file the manifest was read from, absolute - for a command that has to say which file it
57
+ * changed (a commit's path list). Empty when no provider recognized this directory. */
58
+ manifestFileName: string;
59
+ /**
60
+ * **Which ecosystem this package belongs to** - `'node'` for one read by `rman-node`, from the
61
+ * `ManifestProvider.name` that claimed the directory. Empty when no provider did.
62
+ *
63
+ * The escape hatch for code that legitimately knows one technology: `if (pkg.provider === 'node')`
64
+ * before reaching into `manifest.raw` for something only npm has. Per *package*, not per
65
+ * repository, because `Manifest.read` asks per directory - a polyglot monorepo can hold a `node`
66
+ * package beside a `cargo` one, and a command sweeping over `getPackages()` has to be able to
67
+ * tell.
68
+ *
69
+ * Not a union type on purpose: the set of ecosystems is whatever the repository's `plugins`
70
+ * contribute, so narrowing it here would mean the core listing plugins it cannot know about.
71
+ */
72
+ provider: string;
8
73
  constructor(dirname: string);
9
74
  get basename(): string;
10
75
  get name(): string;
11
76
  get version(): string;
12
- get json(): any;
13
- get jsonFileName(): string;
14
77
  get isPrivate(): boolean;
15
- reloadJson(): any;
16
- writeJson(): void;
78
+ /** Re-reads from disk - for a command that has just written the manifest itself and wants the
79
+ * package to agree with the file again. */
80
+ reloadManifest(): Manifest;
81
+ /** Writes the current manifest back through its provider. */
82
+ writeManifest(): void;
17
83
  }
package/core/package.js CHANGED
@@ -1,44 +1,106 @@
1
- import fs from 'fs';
2
1
  import path from 'path';
2
+ import { Manifest } from './manifest.js';
3
+ import { semverScheme } from './version-scheme.js';
3
4
  export class Package {
4
5
  dirname;
5
- _json;
6
+ /**
7
+ * This package's identity, read through whichever `ManifestProvider` recognizes its directory.
8
+ *
9
+ * There is no `json` here any more, and that is the point: `package.json` is npm's answer to
10
+ * "where is a package's name and version written", not rman's. `manifest.raw` is still the whole
11
+ * document for a command that knows its own ecosystem - `rman-node` reads `scripts` and
12
+ * `publishConfig` off it - but the core only ever touches `name`, `version` and `private`.
13
+ */
14
+ manifest;
15
+ /**
16
+ * In-repo packages this one depends on - **the full transitive closure**, not just its direct
17
+ * dependencies (see `Repository._updateDependencies`).
18
+ *
19
+ * References, not names: a name is only an identifier if the ecosystem guarantees uniqueness,
20
+ * which npm does and others do not - Go identifies a module by import path, and nothing stops a
21
+ * repository from holding two packages whose short names collide. A `Package` is unambiguous
22
+ * whatever the ecosystem, and comparing by identity removes a lookup from every consumer.
23
+ */
6
24
  dependencies = [];
7
- /** Effective rman config for this package, cascaded from the repository root. */
25
+ /** Effective rman config for this package, cascaded from the repository root, with every
26
+ * `${{ ... }}` expression already evaluated. */
8
27
  config = {};
28
+ /**
29
+ * The repository this package belongs to - so anything holding a package can reach the whole
30
+ * picture (its siblings, the root's config, git) without being handed it separately.
31
+ *
32
+ * Assigned by `Repository.create` rather than taken as a constructor argument, and it has to be:
33
+ * `Repository extends Package`, so a repository constructing itself runs this constructor before
34
+ * it exists to be passed in. A repository's own is itself.
35
+ */
36
+ repository;
37
+ /**
38
+ * The package whose directory contains this one - the repository root for an ordinary member, and
39
+ * a genuine enclosing package for one nested inside another (which `Repository.currentPackage`
40
+ * already has to reason about). `undefined` for the root itself, which nothing contains.
41
+ */
42
+ parent;
43
+ /**
44
+ * How this package's versions are numbered - `pkg.versionScheme.next(pkg.version, 'minor')`.
45
+ *
46
+ * Comes from the same provider that read the manifest, because the ecosystem that decides where
47
+ * a version is written is the one that decides how it is numbered - a `pyproject.toml` read with
48
+ * semver's arithmetic would be a pair that never occurs in reality.
49
+ *
50
+ * Defaults to semver, so this seam existing changes nothing. **A group must not mix schemes** -
51
+ * a group is one version line, and comparing across schemes is meaningless; `assertOneScheme`
52
+ * refuses it rather than acting on whatever falls out.
53
+ */
54
+ versionScheme = semverScheme;
55
+ /** The file the manifest was read from, absolute - for a command that has to say which file it
56
+ * changed (a commit's path list). Empty when no provider recognized this directory. */
57
+ manifestFileName;
58
+ /**
59
+ * **Which ecosystem this package belongs to** - `'node'` for one read by `rman-node`, from the
60
+ * `ManifestProvider.name` that claimed the directory. Empty when no provider did.
61
+ *
62
+ * The escape hatch for code that legitimately knows one technology: `if (pkg.provider === 'node')`
63
+ * before reaching into `manifest.raw` for something only npm has. Per *package*, not per
64
+ * repository, because `Manifest.read` asks per directory - a polyglot monorepo can hold a `node`
65
+ * package beside a `cargo` one, and a command sweeping over `getPackages()` has to be able to
66
+ * tell.
67
+ *
68
+ * Not a union type on purpose: the set of ecosystems is whatever the repository's `plugins`
69
+ * contribute, so narrowing it here would mean the core listing plugins it cannot know about.
70
+ */
71
+ provider;
9
72
  constructor(dirname) {
10
73
  this.dirname = dirname;
11
- this.reloadJson();
74
+ const { manifest, versionScheme, fileName, provider } = Manifest.read(dirname);
75
+ this.manifest = manifest;
76
+ this.versionScheme = versionScheme;
77
+ this.manifestFileName = fileName ? path.join(dirname, fileName) : '';
78
+ this.provider = provider;
12
79
  }
13
80
  get basename() {
14
81
  return path.basename(this.dirname);
15
82
  }
16
83
  get name() {
17
- return this._json.name;
84
+ return this.manifest.name;
18
85
  }
19
86
  get version() {
20
- return this._json.version;
21
- }
22
- get json() {
23
- return this._json;
24
- }
25
- get jsonFileName() {
26
- return path.join(this.dirname, 'package.json');
87
+ return this.manifest.version;
27
88
  }
28
89
  get isPrivate() {
29
- return !!this._json.private;
90
+ return !!this.manifest.private;
30
91
  }
31
- reloadJson() {
32
- const f = this.jsonFileName;
33
- if (!fs.existsSync(f)) {
34
- throw new Error(`Package.json not found at ${f}`);
35
- }
36
- this._json = JSON.parse(fs.readFileSync(f, 'utf-8'));
37
- return this._json;
92
+ /** Re-reads from disk - for a command that has just written the manifest itself and wants the
93
+ * package to agree with the file again. */
94
+ reloadManifest() {
95
+ const { manifest, versionScheme, fileName, provider } = Manifest.read(this.dirname);
96
+ this.manifest = manifest;
97
+ this.versionScheme = versionScheme;
98
+ this.manifestFileName = fileName ? path.join(this.dirname, fileName) : '';
99
+ this.provider = provider;
100
+ return this.manifest;
38
101
  }
39
- writeJson() {
40
- const f = this.jsonFileName;
41
- const data = JSON.stringify(this._json, undefined, 2);
42
- fs.writeFileSync(f, data, 'utf-8');
102
+ /** Writes the current manifest back through its provider. */
103
+ writeManifest() {
104
+ Manifest.write(this.dirname, this.manifest);
43
105
  }
44
106
  }
@@ -0,0 +1,112 @@
1
+ import type { RmanConfig } from '../interfaces/rman-config.interface.js';
2
+ import { RunService } from '../services/run.service.js';
3
+ import { VersionPlanService } from '../services/version-plan.service.js';
4
+ import { BinPath } from '../utils/bin-path.js';
5
+ import type { CustomCommand, LoadedCommand } from './custom-command.js';
6
+ import { type ManifestProvider } from './manifest.js';
7
+ import { Workspace } from './workspace.js';
8
+ /** The `.rmanrc` key naming plugin packages to load. */
9
+ export declare const PLUGINS_KEY = "plugins";
10
+ /**
11
+ * What a plugin package hands rman.
12
+ *
13
+ * An object rather than a bare array of commands, for the reason `CommandContext` is one: a plugin
14
+ * will eventually contribute more than commands (config defaults, publish targets, a package
15
+ * provider for a non-Node repository), and nothing written against this should have to change when
16
+ * it does.
17
+ */
18
+ export interface RmanPlugin {
19
+ /** For error messages and `--help` grouping. Conventionally the package's own name. */
20
+ name: string;
21
+ commands?: CustomCommand[];
22
+ /**
23
+ * Where `run` can find a package's steps besides its `.rmanrc` - `rman-node` contributes
24
+ * `package.json#scripts` here, with npm's `pre`/`post` lifecycle.
25
+ *
26
+ * Registered in `plugins` declaration order, and only for plugins the repository actually named:
27
+ * what a script resolves to is then a function of the config rather than of what happened to be
28
+ * imported.
29
+ */
30
+ runSteps?: RunService.StepSource;
31
+ /**
32
+ * How this ecosystem's repositories are laid out - `rman-node` reads `workspaces` from the root
33
+ * `package.json` here.
34
+ *
35
+ * Loaded **before any package is known**, since this is what finds them: `Repository.create`
36
+ * reads the root config, loads the plugins it names, and only then asks. A repository naming no
37
+ * plugin therefore has no packages beyond itself.
38
+ */
39
+ workspace?: Workspace.Provider;
40
+ /**
41
+ * Where a package's name and version are written, and how it is numbered - `rman-node`
42
+ * contributes `package.json` here.
43
+ *
44
+ * Registered before any package is constructed, since `Package` reads through it. A repository
45
+ * naming no plugin therefore gets packages named after their own directories at version
46
+ * `0.0.0` - see `readManifest`.
47
+ */
48
+ manifest?: ManifestProvider;
49
+ /**
50
+ * How a release is planned - which packages have changed since their last release and what
51
+ * version each gets. `rman-node` contributes `NodeVersionPlanService` here.
52
+ *
53
+ * **`VersionPlanService` is abstract, so `version`/`changed` do not work without one** (they fail
54
+ * naming this key). Unlike `manifest` and `workspace`, which degrade to honest defaults, a plan is
55
+ * either right or it quietly releases the wrong set of packages - see `VersionPlanService`.
56
+ *
57
+ * Consulted when a command asks, not at load time, so this is declared and nothing else has to
58
+ * happen as the plugin's module is imported.
59
+ */
60
+ versionPlanner?: VersionPlanService;
61
+ /**
62
+ * Where this ecosystem keeps a repository's locally installed executables - `rman-node`
63
+ * contributes npm's `node_modules/.bin`, walked up the directory chain.
64
+ *
65
+ * Prepended to PATH for every `exec`/`runBin` child process, so a command an author wrote runs
66
+ * against the repository's own pinned tools. **Every plugin's entries are used**, not just the
67
+ * first - see `BinPath`.
68
+ */
69
+ binPaths?: BinPath.Provider;
70
+ }
71
+ /**
72
+ * Identity helper for authoring a plugin with full type-checking - the `defineConfig`/
73
+ * `defineCommand` pattern again, for the same reason. Returns `plugin` unchanged.
74
+ *
75
+ * **A plugin package's entry point exports a *config*, not this**, so that a package is an
76
+ * `.rmanrc` like any other and can carry a second plugin later without changing shape:
77
+ *
78
+ * ```js
79
+ * // rman-node's entry point
80
+ * import { defineConfig, definePlugin } from 'rman';
81
+ * import publishCommand from './commands/publish.js';
82
+ *
83
+ * export const nodePlugin = definePlugin({ name: 'rman-node', commands: [publishCommand] });
84
+ * export default defineConfig({ plugins: [nodePlugin] });
85
+ * ```
86
+ *
87
+ * **A module exporting the plugin itself is refused**, with a message saying so. Accepting both
88
+ * would mean telling a plugin from a config at runtime, and `name` is a key either may have - the
89
+ * test would be a guess, and guessing "plugin" registers nothing while reporting success.
90
+ */
91
+ export declare function definePlugin(plugin: RmanPlugin): RmanPlugin;
92
+ /**
93
+ * Loads every plugin the repository's `.rmanrc "plugins"` declares, in declaration order.
94
+ *
95
+ * This is what lets a *package* contribute commands. `.rman/*.mjs` covers one repository's own
96
+ * commands (see `loadCustomCommands`); a plugin covers a whole class of repository - `rman-node`
97
+ * carrying everything that only means something because the repository is a Node one, so rman's
98
+ * core does not have to.
99
+ *
100
+ * An entry is a package name, a path, or a plugin object - and a named package's entry point
101
+ * exports a **config**, whose own `plugins` are then loaded the same way (see `loadInto`).
102
+ *
103
+ * Names resolve relative to the repository root's own `node_modules`, the way `extends` resolves -
104
+ * a plugin is the repository's dependency, not rman's.
105
+ *
106
+ * **A plugin that cannot be loaded throws**, unlike a broken `.rman/*.mjs` file, which is warned
107
+ * about and skipped. The two differ because the consequence does: a skipped local command affects
108
+ * only itself, while a missing plugin silently removes commands the repository is built around -
109
+ * `rman publish` would simply not exist, and "not a known command" sends the reader looking in the
110
+ * wrong place entirely.
111
+ */
112
+ export declare function loadPlugins(rootDir: string, rootConfig: RmanConfig): Promise<LoadedCommand[]>;
package/core/plugin.js ADDED
@@ -0,0 +1,189 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { RunService } from '../services/run.service.js';
4
+ import { VersionPlanService } from '../services/version-plan.service.js';
5
+ import { BinPath } from '../utils/bin-path.js';
6
+ import { Manifest } from './manifest.js';
7
+ import { resolveConfigTarget } from './resolve-target.js';
8
+ import { Workspace } from './workspace.js';
9
+ /** The `.rmanrc` key naming plugin packages to load. */
10
+ export const PLUGINS_KEY = 'plugins';
11
+ /**
12
+ * Identity helper for authoring a plugin with full type-checking - the `defineConfig`/
13
+ * `defineCommand` pattern again, for the same reason. Returns `plugin` unchanged.
14
+ *
15
+ * **A plugin package's entry point exports a *config*, not this**, so that a package is an
16
+ * `.rmanrc` like any other and can carry a second plugin later without changing shape:
17
+ *
18
+ * ```js
19
+ * // rman-node's entry point
20
+ * import { defineConfig, definePlugin } from 'rman';
21
+ * import publishCommand from './commands/publish.js';
22
+ *
23
+ * export const nodePlugin = definePlugin({ name: 'rman-node', commands: [publishCommand] });
24
+ * export default defineConfig({ plugins: [nodePlugin] });
25
+ * ```
26
+ *
27
+ * **A module exporting the plugin itself is refused**, with a message saying so. Accepting both
28
+ * would mean telling a plugin from a config at runtime, and `name` is a key either may have - the
29
+ * test would be a guess, and guessing "plugin" registers nothing while reporting success.
30
+ */
31
+ export function definePlugin(plugin) {
32
+ return plugin;
33
+ }
34
+ /**
35
+ * Loads every plugin the repository's `.rmanrc "plugins"` declares, in declaration order.
36
+ *
37
+ * This is what lets a *package* contribute commands. `.rman/*.mjs` covers one repository's own
38
+ * commands (see `loadCustomCommands`); a plugin covers a whole class of repository - `rman-node`
39
+ * carrying everything that only means something because the repository is a Node one, so rman's
40
+ * core does not have to.
41
+ *
42
+ * An entry is a package name, a path, or a plugin object - and a named package's entry point
43
+ * exports a **config**, whose own `plugins` are then loaded the same way (see `loadInto`).
44
+ *
45
+ * Names resolve relative to the repository root's own `node_modules`, the way `extends` resolves -
46
+ * a plugin is the repository's dependency, not rman's.
47
+ *
48
+ * **A plugin that cannot be loaded throws**, unlike a broken `.rman/*.mjs` file, which is warned
49
+ * about and skipped. The two differ because the consequence does: a skipped local command affects
50
+ * only itself, while a missing plugin silently removes commands the repository is built around -
51
+ * `rman publish` would simply not exist, and "not a known command" sends the reader looking in the
52
+ * wrong place entirely.
53
+ */
54
+ export async function loadPlugins(rootDir, rootConfig) {
55
+ const commands = [];
56
+ /** Resolved against the repository root, where the `.rmanrc` declaring them lives. */
57
+ await loadInto(commands, rootConfig, path.join(rootDir, '.rmanrc'), { files: new Set(), names: new Set() });
58
+ return commands;
59
+ }
60
+ /**
61
+ * One config's `plugins`, in declaration order.
62
+ *
63
+ * Recursive because a plugin package **exports a config**, not a plugin: `rman-node`'s entry point
64
+ * is `export default defineConfig({ plugins: [ ... ] })`, so resolving a name lands on another
65
+ * config whose own `plugins` are the ones to register. That also means a plugin package can name a
66
+ * plugin of its own and it simply works.
67
+ *
68
+ * `from` is the file the entries are resolved against, and it changes as it descends - an entry in
69
+ * `rman-node`'s config resolves through *its* `node_modules`, not the repository's, the same rule
70
+ * `extends` follows.
71
+ *
72
+ * **Only `plugins` is read out of an imported config.** Its other keys are not merged: a config's
73
+ * way into a repository is `extends`, which is the key that says "merge this underneath mine".
74
+ * Reading them here would make a plugin able to configure a repository by being installed.
75
+ */
76
+ async function loadInto(commands, config, from, seen) {
77
+ const declared = config?.[PLUGINS_KEY];
78
+ if (declared === undefined)
79
+ return;
80
+ for (const entry of Array.isArray(declared) ? declared : [declared]) {
81
+ /**
82
+ * The object form: a JS config handing a plugin over directly, and what a plugin package's own
83
+ * config holds. Nothing to resolve or import.
84
+ *
85
+ * An object here **is** a plugin - it is not guessed at. The one thing checked is that it has a
86
+ * `name`, because everything downstream (the registration guard, `--help` grouping, every error
87
+ * message) is keyed by it.
88
+ */
89
+ if (isPlainObject(entry)) {
90
+ if (typeof entry.name !== 'string' || !entry.name) {
91
+ throw new Error(`A plugin object in "${PLUGINS_KEY}" has no "name" - every other message is keyed by it.`);
92
+ }
93
+ register(commands, entry, entry.name, from, seen);
94
+ continue;
95
+ }
96
+ if (typeof entry !== 'string' || !entry.trim()) {
97
+ throw new Error(`"${PLUGINS_KEY}" takes a package name, a path, or a plugin object - not ${JSON.stringify(entry)}`);
98
+ }
99
+ const file = resolveConfigTarget(entry, from, PLUGINS_KEY);
100
+ /** A config naming itself, or two naming each other, would otherwise recurse forever. Keyed by
101
+ * resolved file, so the same package reached by two names is still loaded once. */
102
+ if (seen.files.has(file))
103
+ continue;
104
+ seen.files.add(file);
105
+ const mod = await import(pathToFileURL(file).href);
106
+ const exported = mod?.default ?? mod?.plugin;
107
+ /**
108
+ * **A module exports one thing: an rman config.** Not a plugin, and not either-or.
109
+ *
110
+ * Accepting both meant having to *tell them apart*, and there is no reliable way to - `name` is
111
+ * a key a config may have as well, so the test came down to "a name plus at least one of the
112
+ * things a plugin contributes", which is a guess. Guess wrong in the direction of "plugin" and
113
+ * nothing is registered while the command reports success, which is the worst outcome on offer.
114
+ * One shape, one rule, one error.
115
+ */
116
+ if (!isPlainObject(exported) || exported.plugins === undefined) {
117
+ throw new Error(`Plugin "${entry}" must export an rman config - \`export default defineConfig({ plugins: [ ... ] })\`. ` +
118
+ describeExport(exported));
119
+ }
120
+ await loadInto(commands, exported, file, seen);
121
+ }
122
+ }
123
+ /**
124
+ * Everything a plugin contributes, in one place - so the object and the imported forms cannot drift
125
+ * apart in what they support.
126
+ *
127
+ * **One registration per plugin name.** `plugins` appends at every layer now, so the same plugin
128
+ * arriving twice is an ordinary consequence of `extends` rather than a mistake to report - and
129
+ * registering it twice would define its commands twice, which yargs does not survive. The config
130
+ * merge already drops an identical entry; this catches the rest, including two objects claiming one
131
+ * name and an object that duplicates a named package.
132
+ */
133
+ function register(commands, plugin, label, specifier, seen) {
134
+ if (seen.names.has(plugin.name))
135
+ return;
136
+ seen.names.add(plugin.name);
137
+ if (plugin.runSteps)
138
+ RunService.addStepSource(plugin.runSteps);
139
+ if (plugin.manifest)
140
+ Manifest.addProvider(plugin.manifest);
141
+ if (plugin.workspace)
142
+ Workspace.addProvider(plugin.workspace);
143
+ if (plugin.binPaths)
144
+ BinPath.addProvider(plugin.binPaths);
145
+ if (plugin.versionPlanner)
146
+ VersionPlanService.setPlanner(plugin.versionPlanner);
147
+ for (const command of plugin.commands ?? []) {
148
+ commands.push(toLoadedCommand(command, label || specifier, specifier));
149
+ }
150
+ }
151
+ /**
152
+ * The second half of the error above - what the module *did* export, so the author can see how far
153
+ * off it was.
154
+ *
155
+ * It recognizes a plugin object only to **say so in a message**. That is the one safe use for this
156
+ * shape test: it decides nothing, so a wrong guess costs a slightly less helpful sentence rather
157
+ * than a plugin that silently does not load.
158
+ */
159
+ function describeExport(exported) {
160
+ if (exported === undefined)
161
+ return 'It has no default export.';
162
+ if (!isPlainObject(exported))
163
+ return `Its default export is a ${typeof exported}.`;
164
+ const looksLikePlugin = PLUGIN_SEAMS.some(seam => exported[seam] !== undefined);
165
+ return looksLikePlugin
166
+ ? `Its default export looks like the plugin itself - put it in a config's "${PLUGINS_KEY}".`
167
+ : `Its default export has no "${PLUGINS_KEY}".`;
168
+ }
169
+ const PLUGIN_SEAMS = ['commands', 'runSteps', 'workspace', 'manifest', 'versionPlanner', 'binPaths'];
170
+ /** A config object, as opposed to an array or anything with its own prototype. */
171
+ function isPlainObject(value) {
172
+ return !!value && typeof value === 'object' && !Array.isArray(value);
173
+ }
174
+ /** A plugin's command, checked the same way a `.rman/*.mjs` one is - the name it answers to comes
175
+ * from its own `command` string, since a plugin has no file name to fall back on. */
176
+ function toLoadedCommand(command, pluginName, specifier) {
177
+ const declared = command?.command?.trim();
178
+ if (!declared) {
179
+ throw new Error(`Plugin "${pluginName}" has a command with no "command" name - it cannot be registered.`);
180
+ }
181
+ if (typeof command.handler !== 'function') {
182
+ throw new Error(`Plugin "${pluginName}" command "${declared}" has no "handler" function.`);
183
+ }
184
+ if (typeof command.describe !== 'string' || !command.describe) {
185
+ throw new Error(`Plugin "${pluginName}" command "${declared}" has no "describe" - \`rman --help\` would have ` +
186
+ `nothing to list it by.`);
187
+ }
188
+ return { ...command, command: declared, name: declared.split(/\s+/)[0], file: specifier };
189
+ }