cliguard 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ REST and GraphQL APIs have contract testing (Pact, Specmatic, oasdiff) baked int
11
11
  `cliguard` captures your CLI's real contract - every command, flag, default, and required argument - straight from your CLI framework's own object graph, not by parsing `--help` text. Commit that contract like a snapshot test. From then on, `cliguard check` fails your build the moment a change would break an existing caller, and passes straight through anything additive or cosmetic.
12
12
 
13
13
  ```text
14
- 🔴 [root -> build -> option[--target]] Required option "--target" was removed.
14
+ 🔴 [root -> build -> option[--target]] Option "--target" was removed.
15
15
  🟢 [root -> build -> option[--dry-run]] New optional option "--dry-run" was added.
16
16
  ```
17
17
 
@@ -36,6 +36,26 @@ program.command("build").requiredOption("-t, --target <target>", "build target")
36
36
  module.exports = { program }; // <- cliguard reads this, never runs it
37
37
  ```
38
38
 
39
+ ESM entry files work the same way - `export default program` (or a named export) instead of `module.exports`. cliguard loads your entry through a real dynamic `import()`, so this works even for a target CLI with a top-level `await`.
40
+
41
+ Built with [CAC](https://github.com/cacjs/cac) instead? Export the `CAC` instance the same way (`module.exports = { cli }` / `export default cli`) and pass `--adapter cac`:
42
+
43
+ ```js
44
+ // bin/cli.js
45
+ const { cac } = require("cac");
46
+
47
+ const cli = cac("mycli");
48
+ cli.command("build <entry>", "build target").option("-t, --target <target>", "build target");
49
+
50
+ module.exports = { cli };
51
+ ```
52
+
53
+ ```sh
54
+ npx cliguard init ./bin/cli.js --adapter cac
55
+ ```
56
+
57
+ `cac` itself is an optional dependency of cliguard - only installed if you actually use `--adapter cac`.
58
+
39
59
  Then:
40
60
 
41
61
  ```sh
@@ -81,9 +101,15 @@ jobs:
81
101
 
82
102
  ## Supported frameworks
83
103
 
84
- [Commander.js](https://github.com/tj/commander.js) today - it's what Vue CLI, Prettier, and a large share of the npm CLI ecosystem is built on. The core (types + diff engine) is 100% framework-agnostic by design: every framework-specific detail lives behind the `CliAdapter` interface in [`src/adapters/`](src/adapters/), so adding a new adapter never touches the diffing logic. See the [good first issues](https://github.com/Bryandero98/cliguard/labels/good%20first%20issue) for exactly that.
104
+ [Commander.js](https://github.com/tj/commander.js) (default) and [CAC](https://github.com/cacjs/cac) (`--adapter cac`) today. The core (types + diff engine) is 100% framework-agnostic by design: every framework-specific detail lives behind the `CliAdapter` interface in [`src/adapters/`](src/adapters/), so adding a new adapter never touches the diffing logic. Yargs is the next open gap - see the [good first issue](https://github.com/Bryandero98/cliguard/labels/good%20first%20issue).
105
+
106
+ A couple of `OptionContract`/`ArgumentContract` fields carry real, framework-specific limitations rather than a mapping gap - see [`src/adapters/cac.adapter.ts`](src/adapters/cac.adapter.ts)'s own doc comment for exactly which ones and why (CAC has no declarative "this flag must be passed" concept, and no per-argument description).
107
+
108
+ The current adapter mechanism loads the target CLI's entry file into the Node process (`import()`/`require()`) and reads its object graph directly, so the next targets are other Node frameworks. Cross-language support (Python's Click, Rust's Clap, Go's Cobra) is a real future direction, but needs a different extraction strategy first, since a compiled Clap/Cobra binary can't be `require()`'d into Node the way a JS CLI can - most likely each of those would introspect via a structured `--help` output (some frameworks support a JSON mode) rather than the same in-process approach.
109
+
110
+ ## Security
85
111
 
86
- The current adapter mechanism loads the target CLI's entry file into the Node process (`import()`/`require()`) and reads its object graph directly, so the next targets are other Node frameworks - Yargs and CAC are both open. Cross-language support (Python's Click, Rust's Clap, Go's Cobra) is a real future direction, but needs a different extraction strategy first, since a compiled Clap/Cobra binary can't be `require()`'d into Node the way a JS CLI can - most likely each of those would introspect via a structured `--help` output (some frameworks support a JSON mode) rather than the same in-process approach.
112
+ Extracting a contract runs the target entry file's own top-level code, the same as `node ./bin/cli.js` would - see [SECURITY.md](./SECURITY.md) for what that means in practice.
87
113
 
88
114
  ## Roadmap
89
115
 
@@ -3,7 +3,7 @@ import type { Contract } from "../core/types";
3
3
  * Everything the diff engine and the `cliguard` CLI commands need from a
4
4
  * framework adapter. Each adapter owns exactly one framework's introspection
5
5
  * details; nothing outside `src/adapters/` should ever import a framework
6
- * package (`commander`, eventually `yargs`, etc.) directly.
6
+ * package (`commander`, `cac`, eventually `yargs`) directly.
7
7
  */
8
8
  export interface CliAdapter {
9
9
  /** Adapter identifier stored in `Contract.adapter`, e.g. "commander". */
@@ -0,0 +1,37 @@
1
+ import type { Contract } from "../core/types";
2
+ import type { CliAdapter } from "./adapter.interface";
3
+ /**
4
+ * Extracts a Contract from a target file that exports a `cac()` `CAC`
5
+ * instance. Never parses --help output - every field comes straight from
6
+ * CAC's own object graph (`.commands`, `.globalCommand`, `.options`,
7
+ * `.args`), matching CommanderAdapter's approach.
8
+ *
9
+ * Two real shape differences from Commander, not bugs:
10
+ * - CAC has no declarative "this option must be passed" concept (unlike
11
+ * Commander's `requiredOption`) - `checkOptionValue` (cac's own source)
12
+ * only validates the *value* of an option that was actually passed, so
13
+ * `OptionContract.required` is always `false` here.
14
+ * - CAC's commands are a flat list off the root `CAC` instance, not a
15
+ * tree - there's no nested sub-subcommand concept to recurse into, so
16
+ * every mapped command's own `subcommands` is always `[]`.
17
+ */
18
+ export declare class CacAdapter implements CliAdapter {
19
+ readonly id = "cac";
20
+ extract(entryPath: string): Promise<Contract>;
21
+ private loadCac;
22
+ /** Handles `export default`, `module.exports = cli`, and named exports. */
23
+ private findCac;
24
+ /**
25
+ * CAC's root instance carries global options (`cli.option(...)`,
26
+ * exposed via `globalCommand`) but no description of its own and no
27
+ * positional arguments - those only exist on individual commands.
28
+ */
29
+ private mapRoot;
30
+ /** subcommands is always [] - CAC has no nested sub-subcommand concept to recurse into. */
31
+ private mapCommand;
32
+ private mapOption;
33
+ /** `-x` for a single-character name, `--xray` otherwise - CAC's own `.names` carries neither dash. */
34
+ private dashPrefix;
35
+ /** `isBoolean` is CAC's own flag for a valueless option; anything else declared a value (`<x>` required or `[x]` optional). */
36
+ private inferValueType;
37
+ }
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CacAdapter = void 0;
4
+ const load_module_1 = require("./load-module");
5
+ /**
6
+ * `cac` is an optional peer dependency (see package.json) - a project
7
+ * whose target CLI is built with Commander has no reason to install it.
8
+ * Loaded lazily, only when this adapter actually runs, so importing this
9
+ * file (which `bin.ts` does unconditionally, to register every adapter)
10
+ * never requires `cac` to be present.
11
+ */
12
+ function loadCacClass() {
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate lazy load of an optional peer dependency
15
+ const cacModule = require("cac");
16
+ return cacModule.CAC;
17
+ }
18
+ catch {
19
+ throw new Error("cliguard: the CAC adapter needs the `cac` package. Run `npm install --save-dev cac`.");
20
+ }
21
+ }
22
+ /**
23
+ * Extracts a Contract from a target file that exports a `cac()` `CAC`
24
+ * instance. Never parses --help output - every field comes straight from
25
+ * CAC's own object graph (`.commands`, `.globalCommand`, `.options`,
26
+ * `.args`), matching CommanderAdapter's approach.
27
+ *
28
+ * Two real shape differences from Commander, not bugs:
29
+ * - CAC has no declarative "this option must be passed" concept (unlike
30
+ * Commander's `requiredOption`) - `checkOptionValue` (cac's own source)
31
+ * only validates the *value* of an option that was actually passed, so
32
+ * `OptionContract.required` is always `false` here.
33
+ * - CAC's commands are a flat list off the root `CAC` instance, not a
34
+ * tree - there's no nested sub-subcommand concept to recurse into, so
35
+ * every mapped command's own `subcommands` is always `[]`.
36
+ */
37
+ class CacAdapter {
38
+ constructor() {
39
+ this.id = "cac";
40
+ }
41
+ async extract(entryPath) {
42
+ const cli = await this.loadCac(entryPath);
43
+ return {
44
+ contractVersion: 1,
45
+ adapter: this.id,
46
+ capturedAt: new Date().toISOString(),
47
+ root: this.mapRoot(cli),
48
+ };
49
+ }
50
+ async loadCac(entryPath) {
51
+ const CacClass = loadCacClass();
52
+ const { viaImport, viaRequire } = await (0, load_module_1.loadModule)(entryPath);
53
+ const cli = this.findCac(viaImport.moduleExports, CacClass) ??
54
+ this.findCac(viaRequire.moduleExports, CacClass);
55
+ if (cli)
56
+ return cli;
57
+ // See CommanderAdapter's identical block for why both real errors -
58
+ // not a swallowed, generic guess - matter here.
59
+ throw new Error(`cliguard: no CAC instance found in "${entryPath}". ` +
60
+ "Export it as `export default cli`, `module.exports = cli`, " +
61
+ "or a named export (e.g. `export const cli = cac()`).\n" +
62
+ ` import() failed: ${viaImport.error ?? "module loaded, but exported no CAC instance"}\n` +
63
+ ` require() failed: ${viaRequire.error ?? "module loaded, but exported no CAC instance"}`);
64
+ }
65
+ /** Handles `export default`, `module.exports = cli`, and named exports. */
66
+ findCac(moduleExports, CacClass) {
67
+ if (moduleExports instanceof CacClass) {
68
+ return moduleExports;
69
+ }
70
+ if (moduleExports && typeof moduleExports === "object") {
71
+ const exportsObject = moduleExports;
72
+ if (exportsObject.default instanceof CacClass) {
73
+ return exportsObject.default;
74
+ }
75
+ for (const value of Object.values(exportsObject)) {
76
+ if (value instanceof CacClass)
77
+ return value;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * CAC's root instance carries global options (`cli.option(...)`,
84
+ * exposed via `globalCommand`) but no description of its own and no
85
+ * positional arguments - those only exist on individual commands.
86
+ */
87
+ mapRoot(cli) {
88
+ return {
89
+ name: cli.name,
90
+ description: "",
91
+ aliases: [],
92
+ options: cli.globalCommand.options.map((option) => this.mapOption(option)),
93
+ arguments: [],
94
+ subcommands: cli.commands.map((command) => this.mapCommand(command)),
95
+ };
96
+ }
97
+ /** subcommands is always [] - CAC has no nested sub-subcommand concept to recurse into. */
98
+ mapCommand(command) {
99
+ return {
100
+ name: command.name,
101
+ description: command.description,
102
+ aliases: command.aliasNames,
103
+ options: command.options.map((option) => this.mapOption(option)),
104
+ arguments: command.args.map((arg) => ({
105
+ name: arg.value,
106
+ required: arg.required,
107
+ variadic: arg.variadic,
108
+ // CAC's CommandArg carries no description field - a real
109
+ // framework limitation (positional args are undocumented in
110
+ // CAC's own model), not a mapping gap.
111
+ description: "",
112
+ })),
113
+ subcommands: [],
114
+ };
115
+ }
116
+ mapOption(option) {
117
+ return {
118
+ flags: option.rawName,
119
+ name: option.name,
120
+ aliases: option.names
121
+ .filter((name) => name !== option.name)
122
+ .map((name) => this.dashPrefix(name)),
123
+ description: option.description,
124
+ // CAC has no declarative "must be passed" concept - see class doc.
125
+ required: false,
126
+ valueType: this.inferValueType(option),
127
+ // CAC has no per-option variadic declaration (unlike positional
128
+ // args, which do) - a repeated flag collects into an array at
129
+ // parse time regardless of anything declared statically here.
130
+ variadic: false,
131
+ defaultValue: option.config.default ?? null,
132
+ };
133
+ }
134
+ /** `-x` for a single-character name, `--xray` otherwise - CAC's own `.names` carries neither dash. */
135
+ dashPrefix(name) {
136
+ return name.length === 1 ? `-${name}` : `--${name}`;
137
+ }
138
+ /** `isBoolean` is CAC's own flag for a valueless option; anything else declared a value (`<x>` required or `[x]` optional). */
139
+ inferValueType(option) {
140
+ return option.isBoolean ? "boolean" : "string";
141
+ }
142
+ }
143
+ exports.CacAdapter = CacAdapter;
@@ -10,9 +10,7 @@ import type { CliAdapter } from "./adapter.interface";
10
10
  export declare class CommanderAdapter implements CliAdapter {
11
11
  readonly id = "commander";
12
12
  extract(entryPath: string): Promise<Contract>;
13
- /** Tries `import()` first, then falls back to `require()` for entry points that don't support ESM dynamic import. */
14
13
  private loadCommand;
15
- private tryLoad;
16
14
  /** Handles `export default`, `module.exports = program`, and named exports. */
17
15
  private findCommand;
18
16
  /** Recurses into `command.commands` so root and every subcommand at any depth go through the same mapping. */
@@ -1,41 +1,8 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.CommanderAdapter = void 0;
37
- const path_1 = require("path");
38
4
  const commander_1 = require("commander");
5
+ const load_module_1 = require("./load-module");
39
6
  /**
40
7
  * Extracts a Contract from a target file that exports a Commander.js
41
8
  * `Command` instance. Never parses --help output - every field comes
@@ -56,33 +23,25 @@ class CommanderAdapter {
56
23
  root: this.mapCommand(program),
57
24
  };
58
25
  }
59
- /** Tries `import()` first, then falls back to `require()` for entry points that don't support ESM dynamic import. */
60
26
  async loadCommand(entryPath) {
61
- // A relative entryPath (as typed on the command line) must resolve
62
- // against the caller's cwd, not against this file's own location -
63
- // both import() and require() would otherwise resolve it relative to
64
- // dist/, silently loading the wrong (or no) file.
65
- const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
66
- const viaImport = await this.tryLoad(() => Promise.resolve(`${absolutePath}`).then(s => __importStar(require(s))));
67
- if (viaImport)
68
- return viaImport;
69
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate fallback for target CLIs that aren't import()-able
70
- const viaRequire = await this.tryLoad(() => Promise.resolve(require(absolutePath)));
71
- if (viaRequire)
72
- return viaRequire;
27
+ const { viaImport, viaRequire } = await (0, load_module_1.loadModule)(entryPath);
28
+ const command = this.findCommand(viaImport.moduleExports) ?? this.findCommand(viaRequire.moduleExports);
29
+ if (command)
30
+ return command;
31
+ // Neither attempt's exports contained a Command. One load attempt
32
+ // failing on its own is normal and expected (an ESM-only file can't
33
+ // require(), a CJS one may reject a bare import() on an older Node)
34
+ // - the interesting case is when the file simply never loaded at all
35
+ // (a syntax error, a missing dependency inside it), which the
36
+ // generic "no Command instance found" message below would otherwise
37
+ // misrepresent as "loaded fine, wrong export shape." Surface both
38
+ // real reasons so the actual cause - a broken file vs. a genuinely
39
+ // missing export - is never a guess.
73
40
  throw new Error(`cliguard: no Commander.js Command instance found in "${entryPath}". ` +
74
41
  "Export it as `export default program`, `module.exports = program`, " +
75
- "or a named export (e.g. `export const program = new Command()`).");
76
- }
77
- async tryLoad(load) {
78
- let moduleExports;
79
- try {
80
- moduleExports = await load();
81
- }
82
- catch {
83
- return undefined;
84
- }
85
- return this.findCommand(moduleExports);
42
+ "or a named export (e.g. `export const program = new Command()`).\n" +
43
+ ` import() failed: ${viaImport.error ?? "module loaded, but exported no Command instance"}\n` +
44
+ ` require() failed: ${viaRequire.error ?? "module loaded, but exported no Command instance"}`);
86
45
  }
87
46
  /** Handles `export default`, `module.exports = program`, and named exports. */
88
47
  findCommand(moduleExports) {
@@ -0,0 +1,22 @@
1
+ export interface LoadAttempt {
2
+ /** `undefined` when this attempt threw - see `error` instead. */
3
+ readonly moduleExports: unknown;
4
+ /** Set only when this particular attempt threw. */
5
+ readonly error: unknown;
6
+ }
7
+ export interface LoadResult {
8
+ readonly viaImport: LoadAttempt;
9
+ readonly viaRequire: LoadAttempt;
10
+ }
11
+ /**
12
+ * Loads `entryPath` (resolved against the caller's cwd, not this file's
13
+ * own location) via a real dynamic import() AND require(), always both -
14
+ * never short-circuiting on the first one that merely doesn't throw.
15
+ * A framework instance can end up reachable through only one of the two
16
+ * (e.g. an interop wrapper shifting where a named export lands), so an
17
+ * adapter needs both attempts' exports to search, not just whichever
18
+ * loaded first. Shared by every adapter - none of this is
19
+ * framework-specific; finding the actual Command/CAC/etc. instance
20
+ * inside either attempt's exports is each adapter's own job.
21
+ */
22
+ export declare function loadModule(entryPath: string): Promise<LoadResult>;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadModule = loadModule;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const url_1 = require("url");
7
+ /**
8
+ * A genuine dynamic `import()`, immune to TypeScript's own rewriting.
9
+ * This project builds with `module: "commonjs"` (required for the CLI's
10
+ * own require()-based entrypoint), and under that setting tsc rewrites a
11
+ * literal `import()` expression into `Promise.resolve().then(() =>
12
+ * require(...))` - so a plain `import(absolutePath)` would compile to
13
+ * `require()` in disguise, never a real ESM load, no matter how the
14
+ * source reads. That silently breaks on any target file that's genuine
15
+ * ESM with no synchronous CommonJS-compatible form (e.g. one with a
16
+ * top-level `await`) on any Node version that doesn't support requiring
17
+ * an ESM graph. Constructing the function from a string hides the
18
+ * `import()` call from tsc's static analysis, so this is Node's actual
19
+ * dynamic import at runtime.
20
+ */
21
+ const dynamicImport = new Function("specifier", "return import(specifier)");
22
+ /**
23
+ * Loads `entryPath` (resolved against the caller's cwd, not this file's
24
+ * own location) via a real dynamic import() AND require(), always both -
25
+ * never short-circuiting on the first one that merely doesn't throw.
26
+ * A framework instance can end up reachable through only one of the two
27
+ * (e.g. an interop wrapper shifting where a named export lands), so an
28
+ * adapter needs both attempts' exports to search, not just whichever
29
+ * loaded first. Shared by every adapter - none of this is
30
+ * framework-specific; finding the actual Command/CAC/etc. instance
31
+ * inside either attempt's exports is each adapter's own job.
32
+ */
33
+ async function loadModule(entryPath) {
34
+ const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
35
+ // Checked up front so a caller can tell "the file doesn't exist" apart
36
+ // from "it exists but doesn't export what we're looking for" - both
37
+ // import() and require() otherwise fail the same opaque way for a
38
+ // missing file. Exact-path only, by design: entryPath is documented as
39
+ // a path to a specific entry file, not a resolvable module specifier,
40
+ // so this never has to account for extension-less or directory-index
41
+ // resolution.
42
+ if (!(0, fs_1.existsSync)(absolutePath)) {
43
+ throw new Error(`cliguard: no such file: "${absolutePath}".`);
44
+ }
45
+ // Dynamic import() requires a file:// URL for an absolute filesystem
46
+ // path on Windows - a raw "C:\foo\bar.js" parses as a URL with scheme
47
+ // "c:" and throws ERR_UNSUPPORTED_ESM_URL_SCHEME. pathToFileURL is a
48
+ // no-op in effect on POSIX (still produces a valid file:// URL there).
49
+ const fileUrl = (0, url_1.pathToFileURL)(absolutePath).href;
50
+ const viaImport = await attempt(() => dynamicImport(fileUrl));
51
+ const viaRequire = await attempt(
52
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- deliberate second attempt for entry points that aren't import()-able
53
+ () => Promise.resolve(require(absolutePath)));
54
+ return { viaImport, viaRequire };
55
+ }
56
+ async function attempt(load) {
57
+ try {
58
+ return { moduleExports: await load(), error: undefined };
59
+ }
60
+ catch (error) {
61
+ return { moduleExports: undefined, error };
62
+ }
63
+ }
package/dist/bin.js CHANGED
@@ -2,26 +2,50 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commander_1 = require("commander");
5
+ const cac_adapter_1 = require("./adapters/cac.adapter");
5
6
  const commander_adapter_1 = require("./adapters/commander.adapter");
6
7
  const diff_engine_1 = require("./core/diff.engine");
7
8
  const storage_1 = require("./core/storage");
8
9
  const types_1 = require("./core/types");
9
- const adapter = new commander_adapter_1.CommanderAdapter();
10
+ // Constructing an adapter here is cheap (no eager require of its
11
+ // framework - CacAdapter only loads `cac` lazily, inside extract()), so
12
+ // every adapter is always registered regardless of which one a given
13
+ // invocation actually uses.
14
+ const adapters = {
15
+ commander: new commander_adapter_1.CommanderAdapter(),
16
+ cac: new cac_adapter_1.CacAdapter(),
17
+ };
18
+ function resolveAdapter(name) {
19
+ const adapter = adapters[name];
20
+ if (!adapter) {
21
+ throw new Error(`cliguard: unknown adapter "${name}". Available: ${Object.keys(adapters).join(", ")}.`);
22
+ }
23
+ return adapter;
24
+ }
10
25
  const diffEngine = new diff_engine_1.DiffEngine();
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- package.json has no type declarations to import against; require() is the simplest correct read here
27
+ const packageJson = require("../package.json");
11
28
  const program = new commander_1.Command();
12
29
  program
13
30
  .name("cliguard")
14
- .description("Snapshot-tests your CLI's contract so you never ship a breaking change by accident.");
31
+ .description("Snapshot-tests your CLI's contract so you never ship a breaking change by accident.")
32
+ .version(packageJson.version);
33
+ const adapterOption = [
34
+ "-a, --adapter <name>",
35
+ "CLI framework adapter to use",
36
+ "commander",
37
+ ];
15
38
  program
16
39
  .command("init")
17
40
  .description("Capture the current CLI surface as the committed contract")
18
41
  .argument("<entry>", "path to the target CLI's entry file")
19
- .action(async (entry) => {
42
+ .option(...adapterOption)
43
+ .action(async (entry, options) => {
20
44
  if ((0, storage_1.contractExists)()) {
21
45
  console.warn(`El contrato ya existe. Usa "cliguard update" para sobrescribirlo.`);
22
46
  process.exit(1);
23
47
  }
24
- const contract = await adapter.extract(entry);
48
+ const contract = await resolveAdapter(options.adapter).extract(entry);
25
49
  (0, storage_1.writeContract)(contract);
26
50
  console.log(`✅ Contrato de CLI inicializado con éxito en ${(0, storage_1.getContractDisplayPath)()}.`);
27
51
  });
@@ -29,9 +53,10 @@ program
29
53
  .command("check")
30
54
  .description("Compare the current CLI surface against the committed contract")
31
55
  .argument("<entry>", "path to the target CLI's entry file")
32
- .action(async (entry) => {
56
+ .option(...adapterOption)
57
+ .action(async (entry, options) => {
33
58
  const oldContract = (0, storage_1.readContract)();
34
- const newContract = await adapter.extract(entry);
59
+ const newContract = await resolveAdapter(options.adapter).extract(entry);
35
60
  const diff = diffEngine.compare(oldContract, newContract);
36
61
  if (diff.length === 0) {
37
62
  console.log("✅ El contrato de la CLI está intacto.");
@@ -45,8 +70,9 @@ program
45
70
  .command("update")
46
71
  .description("Overwrite the committed contract with the CLI's current surface")
47
72
  .argument("<entry>", "path to the target CLI's entry file")
48
- .action(async (entry) => {
49
- const contract = await adapter.extract(entry);
73
+ .option(...adapterOption)
74
+ .action(async (entry, options) => {
75
+ const contract = await resolveAdapter(options.adapter).extract(entry);
50
76
  (0, storage_1.writeContract)(contract);
51
77
  console.log("🔄 Contrato de CLI actualizado con éxito.");
52
78
  });
@@ -83,7 +83,7 @@ class DiffEngine {
83
83
  results.push({
84
84
  type: types_1.ChangeType.BREAKING,
85
85
  path: optionPath,
86
- message: `Required option "--${name}" was removed.`,
86
+ message: `Option "--${name}" was removed.`,
87
87
  });
88
88
  continue;
89
89
  }
@@ -18,7 +18,22 @@ function readContract() {
18
18
  if (!(0, fs_1.existsSync)(CONTRACT_PATH)) {
19
19
  throw new Error(`cliguard: no contract found at "${getContractDisplayPath()}". Run \`cliguard init <entry.js>\` first.`);
20
20
  }
21
- return JSON.parse((0, fs_1.readFileSync)(CONTRACT_PATH, "utf-8"));
21
+ const raw = (0, fs_1.readFileSync)(CONTRACT_PATH, "utf-8");
22
+ try {
23
+ return JSON.parse(raw);
24
+ }
25
+ catch (error) {
26
+ // A bare JSON.parse error ("Unexpected token..." with no file
27
+ // context) reads as an internal cliguard bug, not "your committed
28
+ // contract file is corrupted" - which is the actual, fixable cause
29
+ // (a bad manual edit, a botched merge). Naming the file and the
30
+ // fix (re-run init/update) turns a confusing crash into an
31
+ // actionable message.
32
+ const reason = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`cliguard: "${getContractDisplayPath()}" is not valid JSON (${reason}). ` +
34
+ "If this file was hand-edited or came out of a bad merge, re-run " +
35
+ "`cliguard update <entry.js>` to regenerate it.");
36
+ }
22
37
  }
23
38
  function writeContract(contract) {
24
39
  (0, fs_1.mkdirSync)((0, path_1.dirname)(CONTRACT_PATH), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliguard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Snapshot-tests your CLI's contract (commands, flags, defaults) so you never ship a breaking change by accident.",
5
5
  "keywords": ["cli", "contract-testing", "snapshot-testing", "commander", "ci"],
6
6
  "author": "Bryandero98",
@@ -22,17 +22,27 @@
22
22
  "lint": "eslint src --ext .ts",
23
23
  "lint:fix": "eslint src --ext .ts --fix",
24
24
  "format": "prettier --write \"src/**/*.ts\"",
25
+ "pretest": "npm run build",
25
26
  "test": "jest",
26
27
  "test:watch": "jest --watch"
27
28
  },
28
29
  "dependencies": {
29
30
  "commander": "^12.1.0"
30
31
  },
32
+ "peerDependencies": {
33
+ "cac": "^6.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "cac": {
37
+ "optional": true
38
+ }
39
+ },
31
40
  "devDependencies": {
32
41
  "@types/jest": "^29.5.13",
33
42
  "@types/node": "^22.7.4",
34
43
  "@typescript-eslint/eslint-plugin": "^8.8.0",
35
44
  "@typescript-eslint/parser": "^8.8.0",
45
+ "cac": "^6.7.14",
36
46
  "eslint": "^8.57.1",
37
47
  "eslint-config-prettier": "^9.1.0",
38
48
  "jest": "^29.7.0",