@camunda8/cli 2.8.0-alpha.1 → 2.8.0-alpha.11

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 (92) hide show
  1. package/PLUGIN-HELP.md +170 -8
  2. package/README.md +1186 -73
  3. package/dist/command-dispatch.d.ts.map +1 -1
  4. package/dist/command-dispatch.js +5 -3
  5. package/dist/command-dispatch.js.map +1 -1
  6. package/dist/command-framework.d.ts.map +1 -1
  7. package/dist/command-framework.js +22 -1
  8. package/dist/command-framework.js.map +1 -1
  9. package/dist/command-registry.d.ts +61 -311
  10. package/dist/command-registry.d.ts.map +1 -1
  11. package/dist/command-registry.js +64 -33
  12. package/dist/command-registry.js.map +1 -1
  13. package/dist/commands/completion.d.ts +16 -33
  14. package/dist/commands/completion.d.ts.map +1 -1
  15. package/dist/commands/completion.js +31 -689
  16. package/dist/commands/completion.js.map +1 -1
  17. package/dist/commands/mcp-proxy.d.ts +0 -17
  18. package/dist/commands/mcp-proxy.d.ts.map +1 -1
  19. package/dist/commands/mcp-proxy.js +3 -104
  20. package/dist/commands/mcp-proxy.js.map +1 -1
  21. package/dist/commands/open.d.ts +3 -44
  22. package/dist/commands/open.d.ts.map +1 -1
  23. package/dist/commands/open.js +5 -81
  24. package/dist/commands/open.js.map +1 -1
  25. package/dist/commands/plugins.d.ts +23 -8
  26. package/dist/commands/plugins.d.ts.map +1 -1
  27. package/dist/commands/plugins.js +58 -29
  28. package/dist/commands/plugins.js.map +1 -1
  29. package/dist/commands/run.d.ts +8 -8
  30. package/dist/commands/run.d.ts.map +1 -1
  31. package/dist/commands/run.js +60 -60
  32. package/dist/commands/run.js.map +1 -1
  33. package/dist/commands/search.d.ts +2 -39
  34. package/dist/commands/search.d.ts.map +1 -1
  35. package/dist/commands/search.js +2 -83
  36. package/dist/commands/search.js.map +1 -1
  37. package/dist/commands/session.d.ts +1 -0
  38. package/dist/commands/session.d.ts.map +1 -1
  39. package/dist/commands/session.js +5 -0
  40. package/dist/commands/session.js.map +1 -1
  41. package/dist/commands/watch.d.ts +2 -1
  42. package/dist/commands/watch.d.ts.map +1 -1
  43. package/dist/commands/watch.js +17 -8
  44. package/dist/commands/watch.js.map +1 -1
  45. package/dist/completion.d.ts +36 -0
  46. package/dist/completion.d.ts.map +1 -0
  47. package/dist/completion.js +816 -0
  48. package/dist/completion.js.map +1 -0
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/config.js +19 -1
  51. package/dist/config.js.map +1 -1
  52. package/dist/{commands/deployments.d.ts → deployments.d.ts} +1 -1
  53. package/dist/deployments.d.ts.map +1 -0
  54. package/dist/{commands/deployments.js → deployments.js} +8 -8
  55. package/dist/deployments.js.map +1 -0
  56. package/dist/help.d.ts.map +1 -0
  57. package/dist/{commands/help.js → help.js} +68 -4
  58. package/dist/help.js.map +1 -0
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +285 -9
  62. package/dist/index.js.map +1 -1
  63. package/dist/mcp-proxy-helpers.d.ts +23 -0
  64. package/dist/mcp-proxy-helpers.d.ts.map +1 -0
  65. package/dist/mcp-proxy-helpers.js +109 -0
  66. package/dist/mcp-proxy-helpers.js.map +1 -0
  67. package/dist/open-helpers.d.ts +52 -0
  68. package/dist/open-helpers.d.ts.map +1 -0
  69. package/dist/open-helpers.js +88 -0
  70. package/dist/open-helpers.js.map +1 -0
  71. package/dist/plugin-loader.d.ts +102 -2
  72. package/dist/plugin-loader.d.ts.map +1 -1
  73. package/dist/plugin-loader.js +262 -22
  74. package/dist/plugin-loader.js.map +1 -1
  75. package/dist/plugin-version.d.ts +15 -0
  76. package/dist/plugin-version.d.ts.map +1 -0
  77. package/dist/plugin-version.js +37 -0
  78. package/dist/plugin-version.js.map +1 -0
  79. package/dist/search-helpers.d.ts +46 -0
  80. package/dist/search-helpers.d.ts.map +1 -0
  81. package/dist/search-helpers.js +90 -0
  82. package/dist/search-helpers.js.map +1 -0
  83. package/dist/watch-constants.d.ts +7 -0
  84. package/dist/watch-constants.d.ts.map +1 -0
  85. package/dist/watch-constants.js +7 -0
  86. package/dist/watch-constants.js.map +1 -0
  87. package/package.json +11 -7
  88. package/dist/commands/deployments.d.ts.map +0 -1
  89. package/dist/commands/deployments.js.map +0 -1
  90. package/dist/commands/help.d.ts.map +0 -1
  91. package/dist/commands/help.js.map +0 -1
  92. /package/dist/{commands/help.d.ts → help.d.ts} +0 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pure helpers extracted from `src/commands/open.ts` so tests can import
3
+ * them without violating the test→commands import boundary (#291). The
4
+ * `openAppCommand` / `feedbackCommand` handlers in `src/commands/open.ts`
5
+ * re-import these.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { platform } from "node:os";
9
+ import { requireOneOf, requirePositional } from "./command-validation.js";
10
+ import { getLogger } from "./logger.js";
11
+ export const OPEN_APPS = [
12
+ "operate",
13
+ "tasklist",
14
+ "modeler",
15
+ "optimize",
16
+ ];
17
+ export function isAppName(value) {
18
+ // biome-ignore lint/plugin: safe widening — readonly tuple to readonly string[] for .includes() compatibility
19
+ return OPEN_APPS.includes(value);
20
+ }
21
+ /** Pattern that matches a self-managed REST API version suffix, e.g. `/v2` */
22
+ const VERSION_SUFFIX_RE = /\/v\d+\/?$/;
23
+ /**
24
+ * Derive the URL of a Camunda web application from the cluster base URL.
25
+ *
26
+ * Only supported for self-managed clusters where the base URL is the REST API
27
+ * endpoint (e.g. `http://localhost:8080/v2`). Returns `null` when the URL
28
+ * does not look like a self-managed gateway (no `/v<n>` suffix).
29
+ */
30
+ export function deriveAppUrl(baseUrl, app) {
31
+ if (!VERSION_SUFFIX_RE.test(baseUrl)) {
32
+ return null;
33
+ }
34
+ const base = baseUrl.replace(VERSION_SUFFIX_RE, "").replace(/\/$/, "");
35
+ return `${base}/${app}`;
36
+ }
37
+ /**
38
+ * Determine the platform-appropriate command and arguments to open a URL.
39
+ *
40
+ * WSL is detected explicitly because it reports `linux`, but should use the
41
+ * Windows opener via interop rather than relying on `xdg-open`.
42
+ *
43
+ * Accepts optional overrides for testing.
44
+ */
45
+ export function getBrowserCommand(url, plat = platform(), env = process.env) {
46
+ const isWsl = plat === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
47
+ if (plat === "darwin") {
48
+ return { command: "open", args: [url] };
49
+ }
50
+ if (plat === "win32" || isWsl) {
51
+ return { command: "cmd.exe", args: ["/c", "start", "", url] };
52
+ }
53
+ // Linux
54
+ return { command: "xdg-open", args: [url] };
55
+ }
56
+ /**
57
+ * Open a URL in the default system browser.
58
+ * Works on macOS, Linux, native Windows, and WSL.
59
+ */
60
+ export function openUrl(url) {
61
+ const logger = getLogger();
62
+ const { command, args } = getBrowserCommand(url);
63
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
64
+ child.on("error", (error) => {
65
+ if (error.code === "ENOENT") {
66
+ logger.error(`Could not open the browser automatically because '${command}' is not available on PATH.`);
67
+ logger.info(`Open this URL manually: ${url}`);
68
+ return;
69
+ }
70
+ logger.error(`Could not open the browser automatically: ${error.message}`);
71
+ logger.info(`Open this URL manually: ${url}`);
72
+ });
73
+ child.unref();
74
+ }
75
+ const OPEN_USAGE = "Usage: c8 open <app> [--profile <name>]";
76
+ /**
77
+ * Validate raw CLI positional + options for the open command.
78
+ * Returns a fully validated and type-narrowed input object.
79
+ * Exits with code 1 on invalid input.
80
+ */
81
+ export function validateOpenAppOptions(app, options) {
82
+ return {
83
+ app: requireOneOf(requirePositional(app, "Application", OPEN_USAGE), OPEN_APPS, "application", OPEN_USAGE),
84
+ profile: options.profile,
85
+ dryRun: options.dryRun,
86
+ };
87
+ }
88
+ //# sourceMappingURL=open-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-helpers.js","sourceRoot":"","sources":["../src/open-helpers.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,76 @@
1
1
  /**
2
2
  * Plugin loader for dynamic command loading
3
3
  */
4
+ import type { FlagDef } from "./command-registry.ts";
5
+ type CommandHandler = (args: string[], flags?: Record<string, unknown>) => Promise<void>;
6
+ interface CommandWithFlags {
7
+ flags: Record<string, FlagDef>;
8
+ handler: CommandHandler;
9
+ }
10
+ type PluginCommand = CommandHandler | CommandWithFlags;
4
11
  interface PluginCommands {
5
- [commandName: string]: (args: string[]) => Promise<void>;
12
+ [commandName: string]: PluginCommand;
13
+ }
14
+ /**
15
+ * Per-command plugin metadata.
16
+ *
17
+ * A plugin command is **either** metadata-driven (declares typed flags via
18
+ * the `{ flags, handler }` command form) **or** a passthrough command
19
+ * (`passthrough: true` + `passthroughHint`). Mutually exclusive — see
20
+ * #251 / #366. Declaring both is rejected at load time.
21
+ */
22
+ export interface PluginCommandMeta {
23
+ description?: string;
24
+ helpDescription?: string;
25
+ examples?: {
26
+ command: string;
27
+ description: string;
28
+ }[];
29
+ /** Subcommands for shell completion (e.g. cluster → start, stop, status). */
30
+ subcommands?: {
31
+ name: string;
32
+ description: string;
33
+ }[];
34
+ /**
35
+ * If true, c8ctl strips GLOBAL_FLAGS from argv and forwards everything
36
+ * else verbatim to the bare-function handler. Help and JSON help
37
+ * advertise the boundary explicitly.
38
+ *
39
+ * MUST NOT appear together with the `{ flags, handler }` command form;
40
+ * the load-time validator drops any command that declares both.
41
+ */
42
+ passthrough?: boolean;
43
+ /**
44
+ * Required when `passthrough` is true. Short text rendered in help that
45
+ * advertises the boundary, e.g. "Forwards args to `kubectl`".
46
+ */
47
+ passthroughHint?: string;
48
+ /**
49
+ * Optional documentation-only flag list rendered in help under
50
+ * passthrough commands. NOT parsed by c8ctl.
51
+ */
52
+ flagsHint?: string[];
53
+ }
54
+ /**
55
+ * Structured record of a load-time collision between two plugins,
56
+ * surfaced by `c8ctl doctor plugin` (#363). Two flavours:
57
+ *
58
+ * - `command-name`: two plugins exported a command under the same name.
59
+ * The earlier-loaded plugin's command stays in dispatch; the later
60
+ * plugin's was dropped. `winner`/`loser` reflect that ordering.
61
+ * - `plugin-name`: two plugins shared the same `package.json#name`.
62
+ * The entire later plugin was rejected (its module body was never
63
+ * imported); `command` is undefined for this kind.
64
+ *
65
+ * The doctor command is the only consumer; the loader appends to this
66
+ * list as it discovers collisions and never reads from it. Cleared by
67
+ * `clearLoadedPlugins()` so test fixtures stay isolated.
68
+ */
69
+ export interface PluginCollision {
70
+ kind: "command-name" | "plugin-name";
71
+ winner: string;
72
+ loser: string;
73
+ command?: string;
6
74
  }
7
75
  /**
8
76
  * Load all installed plugins from global plugins directory
@@ -15,7 +83,7 @@ export declare function getPluginCommands(): PluginCommands;
15
83
  /**
16
84
  * Execute a plugin command if it exists
17
85
  */
18
- export declare function executePluginCommand(commandName: string, args: string[]): Promise<boolean>;
86
+ export declare function executePluginCommand(commandName: string, args: string[], flags?: Record<string, unknown>): Promise<boolean>;
19
87
  /**
20
88
  * Check if a command is provided by a plugin
21
89
  */
@@ -31,6 +99,7 @@ export interface PluginCommandInfo {
31
99
  commandName: string;
32
100
  pluginName: string;
33
101
  description?: string;
102
+ helpDescription?: string;
34
103
  examples?: {
35
104
  command: string;
36
105
  description: string;
@@ -40,11 +109,42 @@ export interface PluginCommandInfo {
40
109
  name: string;
41
110
  description: string;
42
111
  }[];
112
+ /** True when the command opted into the #366 passthrough contract. */
113
+ passthrough?: boolean;
114
+ /** Required when passthrough is true — short text that names the boundary. */
115
+ passthroughHint?: string;
116
+ /** Optional documentation-only flag list rendered under passthrough help. */
117
+ flagsHint?: string[];
43
118
  }
44
119
  export declare function getPluginCommandsInfo(): PluginCommandInfo[];
120
+ /**
121
+ * True if the named command is a registered passthrough plugin command
122
+ * (#366). Used by the dispatcher to gate the strip-and-forward path.
123
+ */
124
+ export declare function isPassthroughPluginCommand(commandName: string): boolean;
45
125
  /**
46
126
  * Clear all loaded plugins (useful for testing and after uninstall)
47
127
  */
48
128
  export declare function clearLoadedPlugins(): void;
129
+ /**
130
+ * Snapshot of plugin collisions detected at load time (#363). Returns
131
+ * a deep defensive copy of frozen records so callers cannot mutate
132
+ * the loader's bookkeeping (neither the array nor the entries).
133
+ * Order reflects the order in which the loader observed the
134
+ * collisions.
135
+ */
136
+ export declare function getPluginCollisions(): readonly Readonly<PluginCollision>[];
137
+ /**
138
+ * Snapshot of currently loaded plugins (#363). Returns the canonical
139
+ * `package.json#name` of each plugin together with the command names
140
+ * it actually registered (after duplicate-name rejection). Used by
141
+ * `c8ctl doctor plugin` to render an authoritative view of what was
142
+ * loaded vs. what was dropped.
143
+ */
144
+ export interface LoadedPluginSummary {
145
+ name: string;
146
+ commands: string[];
147
+ }
148
+ export declare function getLoadedPluginSummaries(): LoadedPluginSummary[];
49
149
  export {};
50
150
  //# sourceMappingURL=plugin-loader.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin-loader.d.ts","sourceRoot":"","sources":["../src/plugin-loader.ts"],"names":[],"mappings":"AAAA;;GAEG;AAQH,UAAU,cAAc;IACvB,CAAC,WAAW,EAAE,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAiHD;;GAEG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CA2H1D;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,cAAc,CAQlD;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACzC,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EAAE,GACZ,OAAO,CAAC,OAAO,CAAC,CASlB;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,EAAE,CAEhD;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtD,6EAA6E;IAC7E,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACtD;AAED,wBAAgB,qBAAqB,IAAI,iBAAiB,EAAE,CAgB3D;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
1
+ {"version":3,"file":"plugin-loader.d.ts","sourceRoot":"","sources":["../src/plugin-loader.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,147 @@ import { ensurePluginsDir } from "./config.js";
15
15
  import { getLogger } from "./logger.js";
16
16
  import { c8ctl } from "./runtime.js";
17
17
  const loadedPlugins = new Map();
18
+ const pluginCollisions = [];
19
+ /**
20
+ * Validate the passthrough/flags mutual-exclusion rule (#366). Removes
21
+ * offending commands from the registered set so they cannot be invoked,
22
+ * and emits a `logger.warn` naming the plugin and command so the
23
+ * misconfiguration is visible at startup.
24
+ *
25
+ * The contract: a command MUST NOT declare `passthrough: true` AND use the
26
+ * `{ flags, handler }` form. Pick one. A passthrough command without a
27
+ * `passthroughHint` is also rejected (the hint is what makes the boundary
28
+ * legible to users and agents).
29
+ *
30
+ * Mutates `plugin.commands` in place. Safe to call after each plugin is
31
+ * loaded.
32
+ */
33
+ function validatePassthroughCommands(plugin) {
34
+ const logger = getLogger();
35
+ const meta = plugin.metadata?.commands ?? {};
36
+ for (const commandName of Object.keys(plugin.commands)) {
37
+ const commandMeta = meta[commandName];
38
+ if (commandMeta?.passthrough === undefined)
39
+ continue;
40
+ // `passthrough: false` is equivalent to "not opted in" — silently
41
+ // skip. Any other non-`true` value (e.g. the string "true", a
42
+ // number) is a contract violation: dispatch and help both gate on
43
+ // `=== true`, so a truthy non-true value would silently disagree
44
+ // with them. Reject loudly at load time.
45
+ if (commandMeta.passthrough === false)
46
+ continue;
47
+ if (commandMeta.passthrough !== true) {
48
+ logger.warn(`Plugin '${plugin.name}' command '${commandName}' has metadata.passthrough set to ` +
49
+ `${JSON.stringify(commandMeta.passthrough)} but the contract requires the boolean ` +
50
+ "literal `true` (or `false` / omitted to opt out). Dropping this command (#366).");
51
+ delete plugin.commands[commandName];
52
+ continue;
53
+ }
54
+ const cmd = plugin.commands[commandName];
55
+ const hasFlagsForm = typeof cmd !== "function";
56
+ if (hasFlagsForm) {
57
+ logger.warn(`Plugin '${plugin.name}' command '${commandName}' declares both passthrough:true AND flags. ` +
58
+ "Pick one \u2014 a passthrough command must use the bare-function handler form. " +
59
+ "Dropping this command (#366).");
60
+ delete plugin.commands[commandName];
61
+ continue;
62
+ }
63
+ if (typeof commandMeta.passthroughHint !== "string" ||
64
+ commandMeta.passthroughHint.trim() === "") {
65
+ logger.warn(`Plugin '${plugin.name}' command '${commandName}' declares passthrough:true ` +
66
+ "but is missing a non-empty passthroughHint. The hint is required so help and " +
67
+ "agents can advertise the boundary. Dropping this command (#366).");
68
+ delete plugin.commands[commandName];
69
+ continue;
70
+ }
71
+ // `flagsHint` is documentation-only and consumed by the help
72
+ // renderer, which assumes `string[]`. Validate the shape here so a
73
+ // mis-typed value can't reach the renderer. The field is optional;
74
+ // invalid shapes are stripped (not fatal) so the command itself
75
+ // continues to work — only the doc affordance is lost.
76
+ const flagsHint = commandMeta.flagsHint;
77
+ if (flagsHint !== undefined) {
78
+ const valid = Array.isArray(flagsHint) &&
79
+ flagsHint.every((entry) => typeof entry === "string");
80
+ if (!valid) {
81
+ logger.warn(`Plugin '${plugin.name}' command '${commandName}' declares metadata.flagsHint ` +
82
+ "but it is not a string[]. Ignoring flagsHint (#366).");
83
+ delete commandMeta.flagsHint;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ /**
89
+ * Reject duplicate plugin command names at load time. If `plugin` declares
90
+ * a command name that is already registered by an earlier-loaded plugin,
91
+ * drop it from `plugin.commands` and emit `logger.warn` naming both
92
+ * plugins so the conflict is visible at startup.
93
+ *
94
+ * **Conflict policy: first registration wins.** This is an explicit
95
+ * choice (#366) and replaces the previous implicit "last-loaded wins"
96
+ * behaviour produced by `Object.assign` over `loadedPlugins` in
97
+ * insertion order. Plugins cannot override one another by registering
98
+ * the same command name; if you want a different command, give it a
99
+ * different name. Default plugins always load first, so user-installed
100
+ * plugins cannot override default commands by name.
101
+ *
102
+ * This guarantees that the merged map returned by `getPluginCommands()`
103
+ * has a single owning plugin per command name, which keeps dispatch and
104
+ * `isPassthroughPluginCommand()` consistent: the help renderer and the
105
+ * runtime always agree on which plugin handles a given verb.
106
+ *
107
+ * Mutates `plugin.commands` in place. Safe to call after each plugin is
108
+ * loaded; relies on `loadedPlugins` already containing previously-loaded
109
+ * plugins.
110
+ */
111
+ function rejectDuplicateCommandNames(plugin) {
112
+ const logger = getLogger();
113
+ for (const commandName of Object.keys(plugin.commands)) {
114
+ for (const existing of loadedPlugins.values()) {
115
+ if (Object.hasOwn(existing.commands, commandName)) {
116
+ logger.warn(`Plugin '${plugin.name}' tried to register command '${commandName}' but it is ` +
117
+ `already provided by plugin '${existing.name}'. The first registration wins; ` +
118
+ `dropping the duplicate from '${plugin.name}'.`);
119
+ pluginCollisions.push({
120
+ kind: "command-name",
121
+ winner: existing.name,
122
+ loser: plugin.name,
123
+ command: commandName,
124
+ });
125
+ delete plugin.commands[commandName];
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Reject a plugin whose name collides with an already-loaded plugin.
133
+ * This is a separate concern from the command-name collision policy
134
+ * tracked under #363: that policy rejects two plugins exporting the
135
+ * same command name, while this one rejects two plugins sharing the
136
+ * same `package.json#name`. Both follow first-registration-wins.
137
+ * Without this guard a user-installed package sharing a name with a
138
+ * default plugin (or with another already-loaded plugin) would
139
+ * silently overwrite the prior `loadedPlugins.set()` entry, bypassing
140
+ * the command-name policy entirely.
141
+ *
142
+ * Returns `true` when the caller should skip the load; `false` when
143
+ * the name is free.
144
+ */
145
+ function isDuplicatePluginName(pluginName) {
146
+ const logger = getLogger();
147
+ if (loadedPlugins.has(pluginName)) {
148
+ logger.warn(`Plugin name '${pluginName}' is already loaded; refusing to load a second plugin ` +
149
+ `with the same name. The first registration wins.`);
150
+ pluginCollisions.push({
151
+ kind: "plugin-name",
152
+ winner: pluginName,
153
+ loser: pluginName,
154
+ });
155
+ return true;
156
+ }
157
+ return false;
158
+ }
18
159
  /**
19
160
  * Load default plugins bundled with c8ctl
20
161
  */
@@ -44,7 +185,12 @@ async function loadDefaultPlugins() {
44
185
  logger.debug("No default-plugins directory found");
45
186
  return;
46
187
  }
47
- const pluginDirs = readdirSync(defaultPluginsDir);
188
+ // Sort to make load order deterministic across filesystems/OSes.
189
+ // The first-registration-wins duplicate-name policy in
190
+ // `rejectDuplicateCommandNames` relies on this — without a stable
191
+ // sort, "who wins" would depend on `readdirSync()` order, which
192
+ // varies across platforms and filesystems.
193
+ const pluginDirs = readdirSync(defaultPluginsDir).sort();
48
194
  logger.debug(`Found ${pluginDirs.length} default plugin(s)`);
49
195
  for (const pluginDir of pluginDirs) {
50
196
  const pluginPath = join(defaultPluginsDir, pluginDir);
@@ -64,6 +210,13 @@ async function loadDefaultPlugins() {
64
210
  // Read package.json
65
211
  const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
66
212
  const pluginName = packageJson.name || `default-${pluginDir}`;
213
+ // Check for duplicate plugin name BEFORE the dynamic import
214
+ // so a duplicate-name plugin's module code never runs (the
215
+ // import has top-level side effects we don't want to execute
216
+ // only to throw the result away).
217
+ if (isDuplicatePluginName(pluginName)) {
218
+ continue;
219
+ }
67
220
  const pluginFile = existsSync(pluginFileJs)
68
221
  ? pluginFileJs
69
222
  : pluginFileTs;
@@ -72,12 +225,15 @@ async function loadDefaultPlugins() {
72
225
  logger.debug(`Loading default plugin from: ${pluginUrl}`);
73
226
  const plugin = await import(__rewriteRelativeImportExtension(pluginUrl));
74
227
  if (plugin.commands && typeof plugin.commands === "object") {
75
- loadedPlugins.set(pluginName, {
228
+ const loaded = {
76
229
  name: pluginName,
77
- commands: plugin.commands,
230
+ commands: { ...plugin.commands },
78
231
  metadata: plugin.metadata || {},
79
- });
80
- const commandNames = Object.keys(plugin.commands);
232
+ };
233
+ validatePassthroughCommands(loaded);
234
+ rejectDuplicateCommandNames(loaded);
235
+ loadedPlugins.set(pluginName, loaded);
236
+ const commandNames = Object.keys(loaded.commands);
81
237
  logger.debug(`Successfully loaded default plugin: ${pluginName} with ${commandNames.length} commands:`, commandNames);
82
238
  }
83
239
  }
@@ -108,7 +264,12 @@ export async function loadInstalledPlugins() {
108
264
  return;
109
265
  }
110
266
  try {
111
- const entries = readdirSync(nodeModulesPath);
267
+ // Sort to make load order deterministic across filesystems/OSes.
268
+ // The first-registration-wins duplicate-name policy in
269
+ // `rejectDuplicateCommandNames` relies on this — without a stable
270
+ // sort, "who wins" would depend on `readdirSync()` order, which
271
+ // varies across platforms and filesystems.
272
+ const entries = readdirSync(nodeModulesPath).sort();
112
273
  logger.debug(`Scanning ${entries.length} entries in node_modules`);
113
274
  const packagesToScan = [];
114
275
  // Collect regular packages and scoped packages
@@ -117,10 +278,10 @@ export async function loadInstalledPlugins() {
117
278
  continue;
118
279
  }
119
280
  if (entry.startsWith("@")) {
120
- // Scoped package - scan subdirectories
281
+ // Scoped package - scan subdirectories (sorted for determinism).
121
282
  const scopePath = join(nodeModulesPath, entry);
122
283
  try {
123
- const scopedPackages = readdirSync(scopePath);
284
+ const scopedPackages = readdirSync(scopePath).sort();
124
285
  for (const scopedPkg of scopedPackages) {
125
286
  if (!scopedPkg.startsWith(".")) {
126
287
  packagesToScan.push(join(entry, scopedPkg));
@@ -136,6 +297,11 @@ export async function loadInstalledPlugins() {
136
297
  packagesToScan.push(entry);
137
298
  }
138
299
  }
300
+ // Final defensive sort: `@scope/pkg` paths interleave with bare
301
+ // `pkg` paths in the order we appended them, but for
302
+ // duplicate-name resolution we want a single, stable lexicographic
303
+ // order over the full set.
304
+ packagesToScan.sort();
139
305
  logger.debug(`Found ${packagesToScan.length} packages to scan:`, packagesToScan);
140
306
  for (const packageName of packagesToScan) {
141
307
  const packagePath = join(nodeModulesPath, packageName);
@@ -165,18 +331,39 @@ export async function loadInstalledPlugins() {
165
331
  const pluginFile = existsSync(pluginFileJs)
166
332
  ? pluginFileJs
167
333
  : pluginFileTs;
334
+ // Use the package.json#name (not the filesystem directory
335
+ // entry) as the canonical plugin name / loadedPlugins key.
336
+ // Under npm aliases (e.g. `npm i my-alias@npm:real-plugin`),
337
+ // the install directory is `my-alias` but the package name is
338
+ // `real-plugin`. Keying by directory would miss real
339
+ // duplicate-name collisions and surface the wrong name in
340
+ // duplicate warnings. `packageName` is kept for filesystem /
341
+ // logging purposes only.
342
+ const pluginName = typeof packageJson.name === "string" && packageJson.name.length > 0
343
+ ? packageJson.name
344
+ : packageName;
345
+ // Check for duplicate plugin name BEFORE the dynamic import
346
+ // so a duplicate-name plugin's module code never runs (the
347
+ // import has top-level side effects we don't want to execute
348
+ // only to throw the result away).
349
+ if (isDuplicatePluginName(pluginName)) {
350
+ continue;
351
+ }
168
352
  // Use file:// protocol and add timestamp to bust cache
169
353
  const pluginUrl = `file://${pluginFile}?t=${Date.now()}`;
170
354
  logger.debug(`Loading plugin from: ${pluginUrl}`);
171
355
  const plugin = await import(__rewriteRelativeImportExtension(pluginUrl));
172
356
  if (plugin.commands && typeof plugin.commands === "object") {
173
- loadedPlugins.set(packageName, {
174
- name: packageName,
175
- commands: plugin.commands,
357
+ const loaded = {
358
+ name: pluginName,
359
+ commands: { ...plugin.commands },
176
360
  metadata: plugin.metadata || {},
177
- });
178
- const commandNames = Object.keys(plugin.commands);
179
- logger.debug(`Successfully loaded plugin: ${packageName} with ${commandNames.length} commands:`, commandNames);
361
+ };
362
+ validatePassthroughCommands(loaded);
363
+ rejectDuplicateCommandNames(loaded);
364
+ loadedPlugins.set(pluginName, loaded);
365
+ const commandNames = Object.keys(loaded.commands);
366
+ logger.debug(`Successfully loaded plugin: ${pluginName} (dir: ${packageName}) with ${commandNames.length} commands:`, commandNames);
180
367
  }
181
368
  }
182
369
  catch (error) {
@@ -193,7 +380,7 @@ export async function loadInstalledPlugins() {
193
380
  * Get all loaded plugin commands
194
381
  */
195
382
  export function getPluginCommands() {
196
- const allCommands = {};
383
+ const allCommands = Object.create(null);
197
384
  for (const plugin of loadedPlugins.values()) {
198
385
  Object.assign(allCommands, plugin.commands);
199
386
  }
@@ -202,10 +389,23 @@ export function getPluginCommands() {
202
389
  /**
203
390
  * Execute a plugin command if it exists
204
391
  */
205
- export async function executePluginCommand(commandName, args) {
392
+ export async function executePluginCommand(commandName, args, flags) {
206
393
  const commands = getPluginCommands();
207
- if (commands[commandName]) {
208
- await commands[commandName](args);
394
+ const cmd = Object.hasOwn(commands, commandName)
395
+ ? commands[commandName]
396
+ : undefined;
397
+ if (cmd) {
398
+ if (typeof cmd === "function") {
399
+ if (flags !== undefined) {
400
+ await cmd(args, flags);
401
+ }
402
+ else {
403
+ await cmd(args);
404
+ }
405
+ }
406
+ else {
407
+ await cmd.handler(args, flags);
408
+ }
209
409
  return true;
210
410
  }
211
411
  return false;
@@ -215,7 +415,7 @@ export async function executePluginCommand(commandName, args) {
215
415
  */
216
416
  export function isPluginCommand(commandName) {
217
417
  const commands = getPluginCommands();
218
- return commandName in commands;
418
+ return Object.hasOwn(commands, commandName);
219
419
  }
220
420
  /**
221
421
  * Get list of all plugin command names
@@ -227,21 +427,61 @@ export function getPluginCommandsInfo() {
227
427
  const infos = [];
228
428
  for (const plugin of loadedPlugins.values()) {
229
429
  for (const commandName of Object.keys(plugin.commands)) {
430
+ const meta = plugin.metadata?.commands?.[commandName];
230
431
  infos.push({
231
432
  commandName,
232
433
  pluginName: plugin.name,
233
- description: plugin.metadata?.commands?.[commandName]?.description,
234
- examples: plugin.metadata?.commands?.[commandName]?.examples,
235
- subcommands: plugin.metadata?.commands?.[commandName]?.subcommands,
434
+ description: meta?.description,
435
+ helpDescription: meta?.helpDescription,
436
+ examples: meta?.examples,
437
+ subcommands: meta?.subcommands,
438
+ passthrough: meta?.passthrough === true ? true : undefined,
439
+ passthroughHint: meta?.passthroughHint,
440
+ flagsHint: meta?.flagsHint,
236
441
  });
237
442
  }
238
443
  }
239
444
  return infos;
240
445
  }
446
+ /**
447
+ * True if the named command is a registered passthrough plugin command
448
+ * (#366). Used by the dispatcher to gate the strip-and-forward path.
449
+ */
450
+ export function isPassthroughPluginCommand(commandName) {
451
+ for (const plugin of loadedPlugins.values()) {
452
+ if (!Object.hasOwn(plugin.commands, commandName))
453
+ continue;
454
+ const meta = plugin.metadata?.commands?.[commandName];
455
+ if (meta?.passthrough === true)
456
+ return true;
457
+ }
458
+ return false;
459
+ }
241
460
  /**
242
461
  * Clear all loaded plugins (useful for testing and after uninstall)
243
462
  */
244
463
  export function clearLoadedPlugins() {
245
464
  loadedPlugins.clear();
465
+ pluginCollisions.length = 0;
466
+ }
467
+ /**
468
+ * Snapshot of plugin collisions detected at load time (#363). Returns
469
+ * a deep defensive copy of frozen records so callers cannot mutate
470
+ * the loader's bookkeeping (neither the array nor the entries).
471
+ * Order reflects the order in which the loader observed the
472
+ * collisions.
473
+ */
474
+ export function getPluginCollisions() {
475
+ return Object.freeze(pluginCollisions.map((c) => Object.freeze({ ...c })));
476
+ }
477
+ export function getLoadedPluginSummaries() {
478
+ const summaries = [];
479
+ for (const plugin of loadedPlugins.values()) {
480
+ summaries.push({
481
+ name: plugin.name,
482
+ commands: Object.keys(plugin.commands),
483
+ });
484
+ }
485
+ return summaries;
246
486
  }
247
487
  //# sourceMappingURL=plugin-loader.js.map