@camunda8/cli 4.0.0-alpha.3 → 4.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The plugin → host version contract (#523).
3
+ *
4
+ * A plugin declares the c8ctl it needs in its own `package.json`:
5
+ *
6
+ * ```json
7
+ * { "engines": { "c8ctl": ">=4.0.0-alpha.1" } }
8
+ * ```
9
+ *
10
+ * `engines` is the conventional home for a host requirement and npm ignores
11
+ * engine keys it does not know, so declaring one changes nothing about how the
12
+ * plugin installs. The field is optional — a plugin that declares nothing is
13
+ * `undeclared` and behaves exactly as it did before this module existed.
14
+ *
15
+ * Why this exists: the plugin runtime grows over time (`c8ctl.npm()` is the
16
+ * case that prompted #523). Without a declared requirement, a plugin built
17
+ * against a newer runtime fails inside the plugin, at command time, with
18
+ * whatever error the missing API happens to raise — usually a bare
19
+ * `TypeError`. With one, c8ctl can say which version is needed, at install
20
+ * time, before anything is run.
21
+ *
22
+ * Range evaluation is delegated to `semver` — the library npm itself uses for
23
+ * `engines` fields — rather than hand-parsed. That gets the full npm range
24
+ * grammar (set unions, hyphen ranges, wildcards) for free, and correctness for
25
+ * the parts that are easy to get wrong by hand (prerelease ordering, 0.x caret
26
+ * carve-outs, build-metadata stripping). It is a genuine root dependency of
27
+ * this module (see `CORE_DEPENDENCIES` in
28
+ * `tests/unit/root-dependencies-isolation.test.ts`), not something bundled
29
+ * per-plugin the way a default plugin's own dependencies are.
30
+ *
31
+ * **Failing open is a design rule, not an oversight.** Three situations mean
32
+ * "cannot evaluate": an unpublished development build of c8ctl, a host version
33
+ * `semver` cannot parse, and a range `semver` cannot parse. All three yield
34
+ * `unverifiable` and leave the plugin fully working. Disabling a plugin over a
35
+ * question we could not answer would turn a diagnostic into an outage.
36
+ *
37
+ * **Prereleases participate in ranges**, unlike `npm install`'s default. A host
38
+ * on `4.1.0-alpha.3` satisfies `>=4.0.0-alpha.1` here; npm would exclude it
39
+ * without `includePrerelease`. c8ctl publishes alphas, so the npm default would
40
+ * disable plugins on the exact channel their requirement was written for. `^`
41
+ * and `~` still desugar to npm's prerelease-excluding upper bound (`^4.1.0` is
42
+ * `>=4.1.0 <5.0.0-0`); an explicit `<5.0.0` does not, because it says what it
43
+ * says.
44
+ */
45
+ /** The `engines` key a plugin declares its host requirement under. */
46
+ export declare const HOST_ENGINE_KEY = "c8ctl";
47
+ /**
48
+ * - `undeclared` — the plugin declared no requirement (the common case).
49
+ * - `satisfied` — the running c8ctl meets it.
50
+ * - `incompatible` — it does not. The plugin's commands are disabled and
51
+ * `message` explains why.
52
+ * - `unverifiable` — the question could not be answered; the plugin keeps
53
+ * working and `message` says what was skipped.
54
+ */
55
+ export type HostCompatStatus = "undeclared" | "satisfied" | "incompatible" | "unverifiable";
56
+ export interface HostCompatVerdict {
57
+ status: HostCompatStatus;
58
+ /** The declared range, verbatim, when there was one. */
59
+ range?: string;
60
+ /** Ready-to-print explanation. Set for `incompatible` and `unverifiable`. */
61
+ message?: string;
62
+ /**
63
+ * Why the question could not be answered, on `unverifiable` only.
64
+ *
65
+ * Callers log these differently on purpose. `unreadable-range` is a mistake
66
+ * in a published plugin and somebody should hear about it, so it warns.
67
+ * `dev-build` is the expected state of every source checkout, and
68
+ * `unreadable-host-version` is a property of the host rather than of the
69
+ * plugin it would name — warning about either would blame a plugin for
70
+ * something its author cannot fix, on every single invocation.
71
+ */
72
+ reason?: "dev-build" | "unreadable-range" | "unreadable-host-version";
73
+ }
74
+ /**
75
+ * Whether `version` satisfies `range`, evaluated by `semver` with
76
+ * `includePrerelease` on (see the module doc for why).
77
+ *
78
+ * Returns `null` when either `version` or `range` cannot be parsed — callers
79
+ * must treat that as "cannot evaluate", never as "not satisfied".
80
+ */
81
+ export declare function satisfiesRange({ version, range, }: {
82
+ version: string;
83
+ range: string;
84
+ }): boolean | null;
85
+ /**
86
+ * Read a plugin's declared host requirement from its parsed `package.json`.
87
+ * Returns `null` for anything that is not a non-empty string, so a malformed
88
+ * declaration is indistinguishable from no declaration at all.
89
+ */
90
+ export declare function readHostRequirement(packageJson: unknown): string | null;
91
+ /**
92
+ * Decide whether a plugin can run on this c8ctl.
93
+ *
94
+ * `hostVersion` is a parameter rather than a read of the runtime singleton so
95
+ * both this decision and the loader that consumes it can be tested against
96
+ * arbitrary versions — a source checkout always reports the unpublished
97
+ * development version, which by design answers `unverifiable`.
98
+ */
99
+ export declare function checkHostCompat({ pluginName, declaredRange, hostVersion, }: {
100
+ pluginName: string;
101
+ declaredRange: string | null;
102
+ hostVersion: string;
103
+ }): HostCompatVerdict;
104
+ //# sourceMappingURL=plugin-compat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-compat.d.ts","sourceRoot":"","sources":["../../../src/framework/plugins/plugin-compat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAKH,sEAAsE;AACtE,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC;;;;;;;GAOG;AACH,MAAM,MAAM,gBAAgB,GACzB,YAAY,GACZ,WAAW,GACX,cAAc,GACd,cAAc,CAAC;AAElB,MAAM,WAAW,iBAAiB;IACjC,MAAM,EAAE,gBAAgB,CAAC;IACzB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,WAAW,GAAG,kBAAkB,GAAG,yBAAyB,CAAC;CACtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC9B,OAAO,EACP,KAAK,GACL,EAAE;IACF,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,GAAG,IAAI,CAIjB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAQvE;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,EAC/B,UAAU,EACV,aAAa,EACb,WAAW,GACX,EAAE;IACF,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,WAAW,EAAE,MAAM,CAAC;CACpB,GAAG,iBAAiB,CA2DpB"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The plugin → host version contract (#523).
3
+ *
4
+ * A plugin declares the c8ctl it needs in its own `package.json`:
5
+ *
6
+ * ```json
7
+ * { "engines": { "c8ctl": ">=4.0.0-alpha.1" } }
8
+ * ```
9
+ *
10
+ * `engines` is the conventional home for a host requirement and npm ignores
11
+ * engine keys it does not know, so declaring one changes nothing about how the
12
+ * plugin installs. The field is optional — a plugin that declares nothing is
13
+ * `undeclared` and behaves exactly as it did before this module existed.
14
+ *
15
+ * Why this exists: the plugin runtime grows over time (`c8ctl.npm()` is the
16
+ * case that prompted #523). Without a declared requirement, a plugin built
17
+ * against a newer runtime fails inside the plugin, at command time, with
18
+ * whatever error the missing API happens to raise — usually a bare
19
+ * `TypeError`. With one, c8ctl can say which version is needed, at install
20
+ * time, before anything is run.
21
+ *
22
+ * Range evaluation is delegated to `semver` — the library npm itself uses for
23
+ * `engines` fields — rather than hand-parsed. That gets the full npm range
24
+ * grammar (set unions, hyphen ranges, wildcards) for free, and correctness for
25
+ * the parts that are easy to get wrong by hand (prerelease ordering, 0.x caret
26
+ * carve-outs, build-metadata stripping). It is a genuine root dependency of
27
+ * this module (see `CORE_DEPENDENCIES` in
28
+ * `tests/unit/root-dependencies-isolation.test.ts`), not something bundled
29
+ * per-plugin the way a default plugin's own dependencies are.
30
+ *
31
+ * **Failing open is a design rule, not an oversight.** Three situations mean
32
+ * "cannot evaluate": an unpublished development build of c8ctl, a host version
33
+ * `semver` cannot parse, and a range `semver` cannot parse. All three yield
34
+ * `unverifiable` and leave the plugin fully working. Disabling a plugin over a
35
+ * question we could not answer would turn a diagnostic into an outage.
36
+ *
37
+ * **Prereleases participate in ranges**, unlike `npm install`'s default. A host
38
+ * on `4.1.0-alpha.3` satisfies `>=4.0.0-alpha.1` here; npm would exclude it
39
+ * without `includePrerelease`. c8ctl publishes alphas, so the npm default would
40
+ * disable plugins on the exact channel their requirement was written for. `^`
41
+ * and `~` still desugar to npm's prerelease-excluding upper bound (`^4.1.0` is
42
+ * `>=4.1.0 <5.0.0-0`); an explicit `<5.0.0` does not, because it says what it
43
+ * says.
44
+ */
45
+ import semver from "semver";
46
+ import { isRecord, isUnversionedDevBuild } from "../../core/index.js";
47
+ /** The `engines` key a plugin declares its host requirement under. */
48
+ export const HOST_ENGINE_KEY = "c8ctl";
49
+ /**
50
+ * Whether `version` satisfies `range`, evaluated by `semver` with
51
+ * `includePrerelease` on (see the module doc for why).
52
+ *
53
+ * Returns `null` when either `version` or `range` cannot be parsed — callers
54
+ * must treat that as "cannot evaluate", never as "not satisfied".
55
+ */
56
+ export function satisfiesRange({ version, range, }) {
57
+ if (semver.valid(version) === null)
58
+ return null;
59
+ if (semver.validRange(range) === null)
60
+ return null;
61
+ return semver.satisfies(version, range, { includePrerelease: true });
62
+ }
63
+ /**
64
+ * Read a plugin's declared host requirement from its parsed `package.json`.
65
+ * Returns `null` for anything that is not a non-empty string, so a malformed
66
+ * declaration is indistinguishable from no declaration at all.
67
+ */
68
+ export function readHostRequirement(packageJson) {
69
+ if (!isRecord(packageJson))
70
+ return null;
71
+ const { engines } = packageJson;
72
+ if (!isRecord(engines))
73
+ return null;
74
+ const declared = engines[HOST_ENGINE_KEY];
75
+ if (typeof declared !== "string")
76
+ return null;
77
+ const trimmed = declared.trim();
78
+ return trimmed.length > 0 ? trimmed : null;
79
+ }
80
+ /**
81
+ * Decide whether a plugin can run on this c8ctl.
82
+ *
83
+ * `hostVersion` is a parameter rather than a read of the runtime singleton so
84
+ * both this decision and the loader that consumes it can be tested against
85
+ * arbitrary versions — a source checkout always reports the unpublished
86
+ * development version, which by design answers `unverifiable`.
87
+ */
88
+ export function checkHostCompat({ pluginName, declaredRange, hostVersion, }) {
89
+ if (declaredRange === null)
90
+ return { status: "undeclared" };
91
+ if (isUnversionedDevBuild(hostVersion)) {
92
+ return {
93
+ status: "unverifiable",
94
+ range: declaredRange,
95
+ reason: "dev-build",
96
+ message: `Plugin '${pluginName}' requires c8ctl ${declaredRange}, but this is an unpublished ` +
97
+ `development build (${hostVersion}) whose version says nothing about its API surface. ` +
98
+ "Skipping the check — the plugin stays enabled.",
99
+ };
100
+ }
101
+ // Checked before the range, because `satisfiesRange` answers `null` for both
102
+ // "unreadable range" and "unreadable version" and only the plugin author can
103
+ // act on the first. Collapsing them blames a plugin for the host's version
104
+ // string — and since the scaffold ships `engines.c8ctl: "*"`, any host whose
105
+ // version this module cannot parse (a fork, a nightly tag, a hand-edited
106
+ // manifest) would make every scaffolded plugin warn about itself.
107
+ if (semver.valid(hostVersion) === null) {
108
+ return {
109
+ status: "unverifiable",
110
+ range: declaredRange,
111
+ reason: "unreadable-host-version",
112
+ message: `Plugin '${pluginName}' requires c8ctl ${declaredRange}, but this c8ctl reports its version ` +
113
+ `as '${hostVersion}', which is not a version this check can compare. Skipping the check — ` +
114
+ "the plugin stays enabled.",
115
+ };
116
+ }
117
+ const satisfied = satisfiesRange({
118
+ version: hostVersion,
119
+ range: declaredRange,
120
+ });
121
+ if (satisfied === null) {
122
+ return {
123
+ status: "unverifiable",
124
+ range: declaredRange,
125
+ reason: "unreadable-range",
126
+ message: `Plugin '${pluginName}' declares engines.${HOST_ENGINE_KEY} '${declaredRange}', which is not a ` +
127
+ "version range c8ctl understands (npm's semver range syntax — see " +
128
+ "https://github.com/npm/node-semver#ranges). Ignoring the requirement — the plugin stays enabled.",
129
+ };
130
+ }
131
+ if (satisfied)
132
+ return { status: "satisfied", range: declaredRange };
133
+ return {
134
+ status: "incompatible",
135
+ range: declaredRange,
136
+ message: `Plugin '${pluginName}' requires c8ctl ${declaredRange}, but this is c8ctl ${hostVersion}. ` +
137
+ "Upgrade with 'npm install -g @camunda8/cli@latest', or install a plugin release that " +
138
+ `supports c8ctl ${hostVersion}.`,
139
+ };
140
+ }
141
+ //# sourceMappingURL=plugin-compat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-compat.js","sourceRoot":"","sources":["../../../src/framework/plugins/plugin-compat.ts"],"names":[],"mappings":"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"}
@@ -114,14 +114,20 @@ export interface PluginCommandMeta {
114
114
  * surfaced by `c8ctl doctor plugin` (#363). Two flavours:
115
115
  *
116
116
  * - `command-name`: two plugins exported a command under the same name.
117
- * The earlier-loaded plugin's command stays in dispatch; the later
118
- * plugin's was dropped. `winner`/`loser` reflect that ordering.
117
+ * Normally the earlier-loaded plugin's command stays in dispatch and the
118
+ * later plugin's was dropped, so `winner`/`loser` reflect load order. The
119
+ * exception is a `hostIncompatible` incumbent (#523), which loses the name
120
+ * to a working newcomer regardless of order.
119
121
  * - `plugin-name`: two plugins shared the same `package.json#name`.
120
122
  * The entire later plugin was rejected (its module body was never
121
123
  * imported); `command` is undefined for this kind.
122
124
  *
123
- * The doctor command is the only consumer; the loader appends to this
124
- * list as it discovers collisions and never reads from it. Cleared by
125
+ * `winner` always names the plugin that ends up owning the command. When a
126
+ * takeover supersedes an earlier decision, `rejectDuplicateCommandNames`
127
+ * re-points the affected records rather than dropping them — the losers are
128
+ * still losers, and erasing their records would hide a real collision.
129
+ *
130
+ * The doctor command is the only external consumer. Cleared by
125
131
  * `clearLoadedPlugins()` so test fixtures stay isolated.
126
132
  */
127
133
  export interface PluginCollision {
@@ -130,6 +136,30 @@ export interface PluginCollision {
130
136
  loser: string;
131
137
  command?: string;
132
138
  }
139
+ /**
140
+ * Structured record of a plugin whose declared `engines.c8ctl` this c8ctl does
141
+ * not satisfy (#523), surfaced by `c8ctl doctor plugin`.
142
+ *
143
+ * The plugin stays loaded and keeps its place in help — what changed is that its
144
+ * commands now refuse to run and print `message` instead (bar any a working
145
+ * plugin has taken over). Removing it from help would trade one confusing
146
+ * failure ("unknown command os") for another; leaving it visible means the
147
+ * command that a user or an agent already knows about is the one that explains
148
+ * itself.
149
+ *
150
+ * The loader only appends and the doctor command only reads;
151
+ * `clearLoadedPlugins()` resets it so test fixtures stay isolated.
152
+ */
153
+ export interface PluginIncompatibility {
154
+ plugin: string;
155
+ /** The plugin's own version, for "which release did this come from". */
156
+ pluginVersion: string;
157
+ /** The declared range, verbatim. */
158
+ required: string;
159
+ /** The c8ctl the requirement was evaluated against. */
160
+ running: string;
161
+ message: string;
162
+ }
133
163
  /**
134
164
  * Compute the candidate `default-plugins` directories, relative to the
135
165
  * directory containing this loader module.
@@ -149,8 +179,14 @@ export interface PluginCollision {
149
179
  export declare function defaultPluginsCandidateDirs(loaderDir: string): string[];
150
180
  /**
151
181
  * Load all installed plugins from global plugins directory
182
+ *
183
+ * `hostVersion` defaults to the running c8ctl and exists so the `engines.c8ctl`
184
+ * enforcement (#523) can be exercised against other versions: a source checkout
185
+ * reports the unpublished development version, which by design skips the check.
152
186
  */
153
- export declare function loadInstalledPlugins(): Promise<void>;
187
+ export declare function loadInstalledPlugins({ hostVersion, }?: {
188
+ hostVersion?: string;
189
+ }): Promise<void>;
154
190
  /**
155
191
  * Get all loaded plugin commands
156
192
  */
@@ -216,6 +252,16 @@ export declare function getPluginCommandsInfo(): PluginCommandInfo[];
216
252
  * (#366). Used by the dispatcher to gate the strip-and-forward path.
217
253
  */
218
254
  export declare function isPassthroughPluginCommand(commandName: string): boolean;
255
+ /**
256
+ * True if the named command belongs to a plugin disabled by its declared
257
+ * `engines.c8ctl` (#523).
258
+ *
259
+ * The dispatcher uses this to skip flag validation for such a command: its
260
+ * handler exists only to report which c8ctl the plugin needs, and rejecting the
261
+ * invocation for a missing required flag first would replace that explanation
262
+ * with an instruction the user cannot usefully follow.
263
+ */
264
+ export declare function isHostIncompatiblePluginCommand(commandName: string): boolean;
219
265
  /**
220
266
  * Clear all loaded plugins (useful for testing and after uninstall)
221
267
  */
@@ -228,6 +274,12 @@ export declare function clearLoadedPlugins(): void;
228
274
  * collisions.
229
275
  */
230
276
  export declare function getPluginCollisions(): readonly Readonly<PluginCollision>[];
277
+ /**
278
+ * Snapshot of plugins whose declared `engines.c8ctl` this c8ctl does not
279
+ * satisfy (#523). Same defensive-copy contract as
280
+ * {@link getPluginCollisions}; order reflects load order.
281
+ */
282
+ export declare function getPluginIncompatibilities(): readonly Readonly<PluginIncompatibility>[];
231
283
  /**
232
284
  * Snapshot of currently loaded plugins (#363). Returns the canonical
233
285
  * `package.json#name` of each plugin together with the command names
@@ -238,6 +290,8 @@ export declare function getPluginCollisions(): readonly Readonly<PluginCollision
238
290
  export interface LoadedPluginSummary {
239
291
  name: string;
240
292
  commands: string[];
293
+ /** Declared `engines.c8ctl` range, when the plugin declared one (#523). */
294
+ requires?: string;
241
295
  }
242
296
  export declare function getLoadedPluginSummaries(): LoadedPluginSummary[];
243
297
  //# sourceMappingURL=plugin-loader.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin-loader.d.ts","sourceRoot":"","sources":["../../../src/framework/plugins/plugin-loader.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plugin-loader.d.ts","sourceRoot":"","sources":["../../../src/framework/plugins/plugin-loader.ts"],"names":[],"mappings":"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"}
@@ -11,9 +11,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  };
12
12
  import { existsSync, readdirSync, readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
- import { c8ctl, ensurePluginsDir, getLogger, } from "../../core/index.js";
14
+ import { c8ctl, ensurePluginsDir, getLogger, SilentError, } from "../../core/index.js";
15
+ import { checkHostCompat, readHostRequirement } from "./plugin-compat.js";
15
16
  const loadedPlugins = new Map();
16
17
  const pluginCollisions = [];
18
+ const pluginIncompatibilities = [];
17
19
  /**
18
20
  * Validate the passthrough/flags mutual-exclusion rule (#366). Removes
19
21
  * offending commands from the registered set so they cannot be invoked,
@@ -97,6 +99,11 @@ function validatePassthroughCommands(plugin) {
97
99
  * different name. Default plugins always load first, so user-installed
98
100
  * plugins cannot override default commands by name.
99
101
  *
102
+ * **One exception (#523):** an incumbent whose declared `engines.c8ctl` this
103
+ * c8ctl does not satisfy loses the name to a working newcomer — its command
104
+ * could only ever throw. A default plugin can never be on the losing side of
105
+ * this, since `enforceHostRequirement` runs for installed plugins only.
106
+ *
100
107
  * This guarantees that the merged map returned by `getPluginCommands()`
101
108
  * has a single owning plugin per command name, which keeps dispatch and
102
109
  * `isPassthroughPluginCommand()` consistent: the help renderer and the
@@ -110,19 +117,54 @@ function rejectDuplicateCommandNames(plugin) {
110
117
  const logger = getLogger();
111
118
  for (const commandName of Object.keys(plugin.commands)) {
112
119
  for (const existing of loadedPlugins.values()) {
113
- if (Object.hasOwn(existing.commands, commandName)) {
114
- logger.warn(`Plugin '${plugin.name}' tried to register command '${commandName}' but it is ` +
115
- `already provided by plugin '${existing.name}'. The first registration wins; ` +
116
- `dropping the duplicate from '${plugin.name}'.`);
120
+ if (!Object.hasOwn(existing.commands, commandName))
121
+ continue;
122
+ // One exception to first-registration-wins: an incumbent that cannot
123
+ // run on this c8ctl (#523) yields to a newcomer that can. Otherwise
124
+ // load order — lexicographic by package path — would decide that a
125
+ // disabled `aaa-plugin` keeps the command name and a working
126
+ // `zzz-plugin` loses it, leaving the user with a command that only
127
+ // ever throws while a functioning implementation sits unreachable.
128
+ // The incumbent keeps its plugin entry in help, but loses this command.
129
+ if (existing.hostIncompatible && !plugin.hostIncompatible) {
130
+ logger.debug(`Plugin '${plugin.name}' takes over command '${commandName}' from ` +
131
+ `'${existing.name}', which is disabled on this c8ctl.`);
132
+ // Re-point, don't delete. Earlier records for this name are still
133
+ // true about who *lost* it — a third plugin that lost to the
134
+ // incumbent has genuinely lost the command — but their `winner` is
135
+ // now stale. Dropping them would erase that plugin's collision from
136
+ // `doctor plugin` entirely; leaving them unedited would credit a win
137
+ // to a plugin that no longer owns the name.
138
+ for (const record of pluginCollisions) {
139
+ if (record.kind === "command-name" &&
140
+ record.command === commandName &&
141
+ record.winner === existing.name) {
142
+ record.winner = plugin.name;
143
+ }
144
+ }
117
145
  pluginCollisions.push({
118
146
  kind: "command-name",
119
- winner: existing.name,
120
- loser: plugin.name,
147
+ winner: plugin.name,
148
+ loser: existing.name,
121
149
  command: commandName,
122
150
  });
123
- delete plugin.commands[commandName];
124
- break;
151
+ // `continue`, not `break`: this function is what maintains "at most
152
+ // one loaded plugin owns a given command name", so there is nothing
153
+ // further to find for this name.
154
+ delete existing.commands[commandName];
155
+ continue;
125
156
  }
157
+ logger.warn(`Plugin '${plugin.name}' tried to register command '${commandName}' but it is ` +
158
+ `already provided by plugin '${existing.name}'. The first registration wins; ` +
159
+ `dropping the duplicate from '${plugin.name}'.`);
160
+ pluginCollisions.push({
161
+ kind: "command-name",
162
+ winner: existing.name,
163
+ loser: plugin.name,
164
+ command: commandName,
165
+ });
166
+ delete plugin.commands[commandName];
167
+ break;
126
168
  }
127
169
  }
128
170
  }
@@ -154,6 +196,73 @@ function isDuplicatePluginName(pluginName) {
154
196
  }
155
197
  return false;
156
198
  }
199
+ /**
200
+ * Enforce a plugin's declared `engines.c8ctl` (#523).
201
+ *
202
+ * On an unmet requirement, every command the plugin registered is replaced with
203
+ * a handler that throws the explanation. The plugin stays in `loadedPlugins` and
204
+ * therefore in help — see {@link PluginIncompatibility} for why disabling is
205
+ * done this way round.
206
+ *
207
+ * Runs *before* the collision passes, which read `hostIncompatible` to let a
208
+ * disabled incumbent lose a command name to a working newcomer. Takes the host
209
+ * version as an argument so the behaviour is testable against versions other
210
+ * than whatever this build happens to report.
211
+ *
212
+ * Mutates `plugin.commands` in place.
213
+ */
214
+ function enforceHostRequirement(plugin, hostVersion) {
215
+ const logger = getLogger();
216
+ const verdict = checkHostCompat({
217
+ pluginName: plugin.name,
218
+ declaredRange: plugin.hostRequirement ?? null,
219
+ hostVersion,
220
+ });
221
+ if (verdict.status === "unverifiable") {
222
+ // Only an unreadable *range* warns: that is an authoring mistake, and the
223
+ // plugin is silently not getting the guarantee it asked for. The other two
224
+ // reasons are properties of the host — a source checkout, or a version
225
+ // string this check cannot parse — and would name a blameless plugin on
226
+ // every single invocation.
227
+ const message = verdict.message ?? "";
228
+ if (verdict.reason === "unreadable-range")
229
+ logger.warn(message);
230
+ else
231
+ logger.debug(message);
232
+ return;
233
+ }
234
+ if (verdict.status !== "incompatible")
235
+ return;
236
+ const message = verdict.message ?? "";
237
+ plugin.hostIncompatible = true;
238
+ // Debug, not warn: unlike a dropped colliding command — which has no other
239
+ // channel to announce itself — a disabled plugin explains itself the moment
240
+ // one of its commands is used, and `doctor plugin` lists it on demand.
241
+ // Warning on every unrelated invocation would be noise with no new signal.
242
+ logger.debug(message);
243
+ pluginIncompatibilities.push({
244
+ plugin: plugin.name,
245
+ pluginVersion: plugin.version,
246
+ required: verdict.range ?? "",
247
+ running: hostVersion,
248
+ message,
249
+ });
250
+ // SilentError: the message is the whole diagnosis and is rendered as one
251
+ // error line by the top-level handler. A plain Error would come back out as
252
+ // "Unexpected error" plus a stack trace — the shape of failure this check
253
+ // exists to replace.
254
+ const refuse = async () => {
255
+ throw new SilentError(message);
256
+ };
257
+ for (const commandName of Object.keys(plugin.commands)) {
258
+ const command = plugin.commands[commandName];
259
+ // Keep the `{ flags, handler }` shape intact: help rendering and flag
260
+ // parsing both branch on it, and a command that loses its flags would
261
+ // fail with a parse error instead of the explanation.
262
+ plugin.commands[commandName] =
263
+ typeof command === "function" ? refuse : { ...command, handler: refuse };
264
+ }
265
+ }
157
266
  /**
158
267
  * Compute the candidate `default-plugins` directories, relative to the
159
268
  * directory containing this loader module.
@@ -269,8 +378,12 @@ async function loadDefaultPlugins() {
269
378
  }
270
379
  /**
271
380
  * Load all installed plugins from global plugins directory
381
+ *
382
+ * `hostVersion` defaults to the running c8ctl and exists so the `engines.c8ctl`
383
+ * enforcement (#523) can be exercised against other versions: a source checkout
384
+ * reports the unpublished development version, which by design skips the check.
272
385
  */
273
- export async function loadInstalledPlugins() {
386
+ export async function loadInstalledPlugins({ hostVersion = c8ctl.version, } = {}) {
274
387
  const logger = getLogger();
275
388
  // Expose the runtime to plugins via globalThis.
276
389
  // C8ctl implements C8ctlPluginRuntime directly — no monkey-patching needed.
@@ -379,13 +492,21 @@ export async function loadInstalledPlugins() {
379
492
  logger.debug(`Loading plugin from: ${pluginUrl}`);
380
493
  const plugin = await import(__rewriteRelativeImportExtension(pluginUrl));
381
494
  if (plugin.commands && typeof plugin.commands === "object") {
495
+ const hostRequirement = readHostRequirement(packageJson);
382
496
  const loaded = {
383
497
  name: pluginName,
384
498
  version: pluginVersion,
385
499
  commands: { ...plugin.commands },
386
500
  metadata: plugin.metadata || {},
501
+ ...(hostRequirement === null ? {} : { hostRequirement }),
387
502
  };
388
503
  validatePassthroughCommands(loaded);
504
+ // Before the collision pass, which needs to know whether either
505
+ // side of a command-name clash is disabled. Installed plugins
506
+ // only: a default plugin ships inside the c8ctl package, so its
507
+ // host is itself and there is no version skew for
508
+ // `engines.c8ctl` (#523) to catch.
509
+ enforceHostRequirement(loaded, hostVersion);
389
510
  rejectDuplicateCommandNames(loaded);
390
511
  loadedPlugins.set(pluginName, loaded);
391
512
  const commandNames = Object.keys(loaded.commands);
@@ -509,12 +630,31 @@ export function isPassthroughPluginCommand(commandName) {
509
630
  }
510
631
  return false;
511
632
  }
633
+ /**
634
+ * True if the named command belongs to a plugin disabled by its declared
635
+ * `engines.c8ctl` (#523).
636
+ *
637
+ * The dispatcher uses this to skip flag validation for such a command: its
638
+ * handler exists only to report which c8ctl the plugin needs, and rejecting the
639
+ * invocation for a missing required flag first would replace that explanation
640
+ * with an instruction the user cannot usefully follow.
641
+ */
642
+ export function isHostIncompatiblePluginCommand(commandName) {
643
+ for (const plugin of loadedPlugins.values()) {
644
+ if (!Object.hasOwn(plugin.commands, commandName))
645
+ continue;
646
+ if (plugin.hostIncompatible === true)
647
+ return true;
648
+ }
649
+ return false;
650
+ }
512
651
  /**
513
652
  * Clear all loaded plugins (useful for testing and after uninstall)
514
653
  */
515
654
  export function clearLoadedPlugins() {
516
655
  loadedPlugins.clear();
517
656
  pluginCollisions.length = 0;
657
+ pluginIncompatibilities.length = 0;
518
658
  }
519
659
  /**
520
660
  * Snapshot of plugin collisions detected at load time (#363). Returns
@@ -526,12 +666,23 @@ export function clearLoadedPlugins() {
526
666
  export function getPluginCollisions() {
527
667
  return Object.freeze(pluginCollisions.map((c) => Object.freeze({ ...c })));
528
668
  }
669
+ /**
670
+ * Snapshot of plugins whose declared `engines.c8ctl` this c8ctl does not
671
+ * satisfy (#523). Same defensive-copy contract as
672
+ * {@link getPluginCollisions}; order reflects load order.
673
+ */
674
+ export function getPluginIncompatibilities() {
675
+ return Object.freeze(pluginIncompatibilities.map((i) => Object.freeze({ ...i })));
676
+ }
529
677
  export function getLoadedPluginSummaries() {
530
678
  const summaries = [];
531
679
  for (const plugin of loadedPlugins.values()) {
532
680
  summaries.push({
533
681
  name: plugin.name,
534
682
  commands: Object.keys(plugin.commands),
683
+ ...(plugin.hostRequirement === undefined
684
+ ? {}
685
+ : { requires: plugin.hostRequirement }),
535
686
  });
536
687
  }
537
688
  return summaries;