cliguard 0.7.3 → 0.8.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
@@ -103,6 +103,14 @@ npx cliguard init ./cli.py --adapter click
103
103
 
104
104
  Unlike the JS adapters, `click` needs a real Python interpreter: cliguard shells out to `python3` (falling back to `python`) with `click` installed in that same environment - there's no in-process way to introspect a Python object from Node. `pip install click` in whichever Python cliguard's shell can already reach is all that's required; nothing npm-installable covers this one.
105
105
 
106
+ Built with [oclif](https://oclif.io/) instead? Point cliguard at the plugin's own root directory (wherever its `package.json` lives) and pass `--adapter oclif`:
107
+
108
+ ```sh
109
+ npx cliguard init ./ --adapter oclif
110
+ ```
111
+
112
+ Unlike every other adapter, this one doesn't need to load your CLI's code at all - oclif already ships a first-class `oclif manifest` command that dumps a complete, structured description of every command's flags and arguments as `oclif.manifest.json`. cliguard reads that file directly if your project already has one (some oclif projects commit it, or produce it as part of their own build), or runs `oclif manifest` itself and cleans up afterward if it doesn't. Either way, `oclif` needs to be a devDependency of the target project - the same one your own `npm run prepack` (or similar) would already need.
113
+
106
114
  ### Entry files that build the CLI lazily
107
115
 
108
116
  Not every real CLI exports its instance - plenty build it inside a function that only runs when something actually calls it, or just never had a reason to export it. Pointing cliguard straight at a file like that would fail with "no instance found" under the rule above alone.
@@ -319,6 +327,18 @@ Off by default so it never changes behavior for an existing CI config - opt in p
319
327
 
320
328
  A `--strict`-only change is real BREAKING output, so `cliguard accept` needs the same flag to find it - `cliguard accept ./bin/cli.js "root -> copy" --strict --reason "..."` - without it, `accept` compares in default (non-strict) mode and won't see the change at all.
321
329
 
330
+ ## Architecture
331
+
332
+ <picture>
333
+ <source media="(prefers-color-scheme: dark)" srcset="docs/architecture-dark.svg">
334
+ <img src="docs/architecture-light.svg" alt="Diagram: cliguard check loads the target CLI through a framework adapter to extract a fresh Contract, compares it against the committed contract.json using DiffEngine, classifies every change as BREAKING, PATCH or ADDITIVE, and exits 1 only if an unacknowledged BREAKING change remains.">
335
+ </picture>
336
+
337
+ The interesting part isn't the diff - it's getting the CLI's true shape out
338
+ of code that may never export it. See "Entry files that build the CLI
339
+ lazily" below for how the adapter reaches a Commander/CAC/Yargs instance
340
+ that's never assigned to anything exported.
341
+
322
342
  ## Programmatic API
323
343
 
324
344
  Everything above is the CLI. The same extraction and diff logic is also available as a library, for a custom build script, monorepo tool, or bot that wants to embed a contract check without spawning `npx cliguard` as a subprocess:
@@ -333,7 +353,7 @@ const diff = compareContracts(oldContract, newContract, { strict: true });
333
353
  const breaking = diff.filter((change) => change.type === ChangeType.BREAKING);
334
354
  ```
335
355
 
336
- `listAdapters()` returns every name `extractContract`'s second argument accepts. `DiffEngine`, every adapter class (`CommanderAdapter`/`CacAdapter`/`YargsAdapter`), and the `toJUnitXml`/`toGitLabCodeQuality`/`toRdjsonl` formatters are all exported too, for anything more custom than the two convenience functions cover.
356
+ `listAdapters()` returns every name `extractContract`'s second argument accepts. `DiffEngine`, every adapter class (`CommanderAdapter`/`CacAdapter`/`YargsAdapter`/`ClickAdapter`/`CobraAdapter`/`OclifAdapter`), and the `toJUnitXml`/`toGitLabCodeQuality`/`toRdjsonl` formatters are all exported too, for anything more custom than the two convenience functions cover.
337
357
 
338
358
  ## How changes get classified
339
359
 
@@ -344,7 +364,7 @@ const breaking = diff.filter((change) => change.type === ChangeType.BREAKING);
344
364
  | **Alias** | 🔴 BREAKING | 🟡 PATCH | - | - |
345
365
  | **Description** | - | - | - | 🟡 PATCH |
346
366
 
347
- Full rules live in [`src/core/diff.engine.ts`](src/core/diff.engine.ts) - it's the one file worth reading if you want to know exactly why something was flagged.
367
+ Every rule cliguard enforces - including how environment variable bindings and each escape hatch (`accept`/`deprecate`/`[unstable]`) factor in - is documented in full in [`RULES.md`](RULES.md), the same spirit as [oasdiff documenting its own ~755 OpenAPI checks](https://github.com/oasdiff/oasdiff) separately from its source. The underlying implementation and its own tests are [`src/core/diff.engine.ts`](src/core/diff.engine.ts) and [`src/__tests__/diff-engine.test.ts`](src/__tests__/diff-engine.test.ts), if `RULES.md` and the code ever disagree.
348
368
 
349
369
  ### Reports for non-GitHub CI
350
370
 
@@ -366,11 +386,42 @@ npx cliguard check ./bin/cli.js --format rdjsonl | reviewdog -f=rdjsonl -reporte
366
386
 
367
387
  Same exit code either way - `1` on an unacknowledged BREAKING change, `0` otherwise - so any of the three drops straight into a CI job that already fails the build on a non-zero exit.
368
388
 
389
+ ### Webhook reporter for SaaS integrations
390
+
391
+ `--webhook <url>` (or a `CLIGUARD_WEBHOOK_URL` environment variable) POSTs the same diff `check` just computed as JSON to a URL of your choice - the first building block toward the hosted dashboard/Slack-alert roadmap below, v1 scoped to just the POST itself:
392
+
393
+ ```sh
394
+ npx cliguard check ./bin/cli.js --webhook https://example.com/cliguard-hook
395
+ ```
396
+
397
+ ```json
398
+ {
399
+ "entry": "./bin/cli.js",
400
+ "repo": "git@github.com:you/your-cli.git",
401
+ "commit": "a1b2c3d4e5f6...",
402
+ "changes": [
403
+ { "type": "BREAKING", "path": "root -> option[--target]", "message": "Option \"--target\" was removed." }
404
+ ]
405
+ }
406
+ ```
407
+
408
+ `repo`/`commit` are best-effort (`git config --get remote.origin.url` / `git rev-parse HEAD`) - `null` outside a git repository. A webhook that's unreachable or slow to respond never fails `check` or changes its exit code - it just prints a warning and moves on.
409
+
410
+ ### `--open-diff`: opening a real difference in your editor
411
+
412
+ Inspired by how [ApprovalTests](https://approvaltests.com/)' reporters open an external diff tool the moment a test fails - `--open-diff` does the same the moment `check` finds a real difference: it writes the expected and actual contracts to temp files and, if [VS Code](https://code.visualstudio.com/)'s own `code` CLI is on PATH, opens its built-in two-pane diff view on them.
413
+
414
+ ```sh
415
+ npx cliguard check ./bin/cli.js --open-diff
416
+ ```
417
+
418
+ No supported editor found on PATH? cliguard never fails hard over it - it just prints both files' paths instead, so you can open them with whatever you have. Either way, `--open-diff` never changes `check`'s own exit code, and the editor (when one opens) is launched detached from cliguard's own process, so `check` still exits immediately rather than waiting on you to close it.
419
+
369
420
  ## CI integration
370
421
 
371
422
  `cliguard init --with-ci` scaffolds the workflow below for you - `git add .github/workflows/cliguard.yml` and you're done. Prefer to see it first, or wire it up by hand? Read on.
372
423
 
373
- The bundled GitHub Action (`Bryandero98/cliguard@v1`) is the recommended way to run this in CI: on top of the same exit-code gate as `npx cliguard check`, it posts the diff as a PR comment - updated in place on every push, not a new one each time - so a reviewer sees exactly what changed without opening the CI log:
424
+ The bundled GitHub Action (`Bryandero98/cliguard@v0`) is the recommended way to run this in CI: on top of the same exit-code gate as `npx cliguard check`, it posts the diff as a PR comment - updated in place on every push, not a new one each time - so a reviewer sees exactly what changed without opening the CI log:
374
425
 
375
426
  ```yaml
376
427
  # .github/workflows/cliguard.yml
@@ -386,7 +437,7 @@ jobs:
386
437
  - uses: actions/setup-node@v4
387
438
  with: { node-version: 22.x }
388
439
  - run: npm ci
389
- - uses: Bryandero98/cliguard@v1
440
+ - uses: Bryandero98/cliguard@v0
390
441
  with:
391
442
  entry: ./bin/cli.js
392
443
  # adapter: yargs # default: commander
@@ -402,11 +453,15 @@ Set `comment-on-pr: false` to keep the exit-code gate without the comment, or us
402
453
 
403
454
  ## Supported frameworks
404
455
 
405
- [Commander.js](https://github.com/tj/commander.js) (default), [CAC](https://github.com/cacjs/cac) (`--adapter cac`), and [Yargs](https://github.com/yargs/yargs) (`--adapter yargs`) 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. Click, Clap, and Cobra are the next open gaps - see the [good first issue](https://github.com/Bryandero98/cliguard/labels/good%20first%20issue).
456
+ [Commander.js](https://github.com/tj/commander.js) (default), [CAC](https://github.com/cacjs/cac) (`--adapter cac`), [Yargs](https://github.com/yargs/yargs) (`--adapter yargs`), Python's [Click](https://click.palletsprojects.com/) (`--adapter click`), and [oclif](https://oclif.io/) (`--adapter oclif`, via its own `oclif manifest` command) today, all requiring zero changes to the target CLI itself.
457
+
458
+ Go's [Cobra](https://github.com/spf13/cobra) (`--adapter cobra`) also exists, but as a **proof of concept only** (see [issue #9](https://github.com/Bryandero98/cliguard/issues/9)) - unlike every adapter above, it needs the target CLI's own author to wire in a hidden dump subcommand (there's no published `cliguard-go` package yet to do that for them). See [`examples/cobra-dump/`](examples/cobra-dump/) for the full example and [`src/adapters/cobra.adapter.ts`](src/adapters/cobra.adapter.ts) for exactly what it does and doesn't cover. Rust's [Clap](https://github.com/clap-rs/clap) (`--adapter clap`) would follow the same pattern (`clap::Command` is introspectable before parsing, same as `clap_complete`/`clap_mangen` already rely on) but isn't built yet - see [issue #10](https://github.com/Bryandero98/cliguard/issues/10).
459
+
460
+ 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.
406
461
 
407
462
  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). Yargs's own real limitation is the opposite kind - see [`src/adapters/yargs.adapter.ts`](src/adapters/yargs.adapter.ts)'s doc comment for why each command's options are read from a fresh, isolated instance rather than the shared one the target CLI actually built.
408
463
 
409
- 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.
464
+ The Commander/CAC/Yargs adapters load the target CLI's entry file into the Node process (`import()`/`require()`) and read its object graph directly - that only works for other Node frameworks. Click and Cobra instead run a small extractor as a subprocess and parse JSON off its stdout, since a Python object graph or a compiled Go binary can't be `require()`'d into Node the way a JS CLI can.
410
465
 
411
466
  ## Security
412
467
 
@@ -25,6 +25,7 @@ class CacAdapter {
25
25
  'OptionContract.required is always false - CAC has no declarative "this option must be passed" concept.',
26
26
  "CommandContract.subcommands is always [] - CAC's commands are a flat list, not a tree.",
27
27
  'ArgumentContract.description is always "" - CAC\'s positional args carry no description field.',
28
+ "OptionContract.envVar is always undefined - CAC has no built-in concept of satisfying a flag from an environment variable.",
28
29
  ];
29
30
  }
30
31
  async extract(entryPath) {
@@ -112,6 +112,17 @@ def main():
112
112
  "required": bool(p.required),
113
113
  "nargs": p.nargs,
114
114
  }
115
+ def resolve_envvar(p):
116
+ # click.Option.envvar can be a single name, a list of names
117
+ # (first-match-wins at parse time), or None when envvar= was
118
+ # never passed - normalized here to "first name or None" so
119
+ # the TS side only ever deals with a single string or absent,
120
+ # matching every other adapter's OptionContract.envVar shape.
121
+ envvar = getattr(p, "envvar", None)
122
+ if isinstance(envvar, (list, tuple)):
123
+ return envvar[0] if envvar else None
124
+ return envvar
125
+
115
126
  return {
116
127
  "type": "Option",
117
128
  "name": p.name,
@@ -122,6 +133,7 @@ def main():
122
133
  "multiple": bool(p.multiple),
123
134
  "default": resolve_default(p),
124
135
  "help": p.help,
136
+ "envvar": resolve_envvar(p),
125
137
  }
126
138
 
127
139
  def dump_command(cmd, name):
@@ -173,6 +185,7 @@ class ClickAdapter {
173
185
  'OptionContract.valueType collapses every non-flag Click option (string, int, float, choice, path, ...) to "string" - Contract only distinguishes boolean vs. everything else, matching how CacAdapter/YargsAdapter already collapse their own richer type systems.',
174
186
  "A --flag/--no-flag paired boolean toggle surfaces as one OptionContract, same as a plain is_flag option - the negative form is only visible informationally inside `flags`, not as a separate field.",
175
187
  "Requires a `python3` or `python` on PATH with `click` installed in that same environment - unlike the JS adapters, which only need the target's own node_modules.",
188
+ "OptionContract.envVar only reflects an explicit `envvar=` on the option - Click's CLI-wide `auto_envvar_prefix` (which derives every option's env var implicitly from its name at parse time, never declared per-option) isn't read. When `envvar=` is a list of names, only the first is surfaced.",
176
189
  ];
177
190
  }
178
191
  async extract(entryPath) {
@@ -242,6 +255,7 @@ class ClickAdapter {
242
255
  valueType: param.is_flag ? "boolean" : "string",
243
256
  variadic: param.multiple ?? false,
244
257
  defaultValue: param.default ?? null,
258
+ envVar: param.envvar ?? undefined,
245
259
  };
246
260
  }
247
261
  mapArgument(param) {
@@ -0,0 +1,35 @@
1
+ import type { Contract } from "../core/types";
2
+ import type { CliAdapter } from "./adapter.interface";
3
+ /**
4
+ * **Proof of concept, not a shipped integration** - see issue #9. Unlike
5
+ * every other adapter, cliguard can't `require()`/`import()` a compiled Go
6
+ * binary into Node, so a Cobra CLI has to expose its own command tree
7
+ * itself: a hidden `__cliguard_dump__` subcommand
8
+ * (examples/cobra-dump/clidump/dump.go's `NewDumpCommand`) prints it as
9
+ * JSON on stdout, and this adapter runs that subcommand as a subprocess
10
+ * and parses the output - no different in spirit from how ClickAdapter
11
+ * already shells out to a real Python interpreter instead of introspecting
12
+ * in-process.
13
+ *
14
+ * A real `cliguard-go` package doesn't exist yet (see the issue) - the
15
+ * target CLI's own author would today have to vendor a copy of
16
+ * examples/cobra-dump/clidump/dump.go directly and wire in
17
+ * `rootCmd.AddCommand(clidump.NewDumpCommand(rootCmd))` themselves. This
18
+ * adapter only proves the rest of the pattern (subprocess -> JSON ->
19
+ * Contract) actually works end to end against a real Cobra CLI.
20
+ *
21
+ * `entryPath` is either:
22
+ * - a compiled Cobra binary - run directly as `<entryPath> __cliguard_dump__`.
23
+ * - a `.go` file or a directory - run via `go run . __cliguard_dump__`
24
+ * (requires a `go` toolchain on PATH), which is how the example CLI
25
+ * under examples/cobra-dump is exercised without a separate build step.
26
+ */
27
+ export declare class CobraAdapter implements CliAdapter {
28
+ readonly id = "cobra";
29
+ readonly limitations: readonly string[];
30
+ extract(entryPath: string): Promise<Contract>;
31
+ private runDump;
32
+ private mapCommand;
33
+ private mapOption;
34
+ private parseDefault;
35
+ }
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CobraAdapter = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ /** The dump subcommand every Cobra target CLI must expose - see examples/cobra-dump/clidump/dump.go. */
8
+ const DUMP_SUBCOMMAND = "__cliguard_dump__";
9
+ /**
10
+ * **Proof of concept, not a shipped integration** - see issue #9. Unlike
11
+ * every other adapter, cliguard can't `require()`/`import()` a compiled Go
12
+ * binary into Node, so a Cobra CLI has to expose its own command tree
13
+ * itself: a hidden `__cliguard_dump__` subcommand
14
+ * (examples/cobra-dump/clidump/dump.go's `NewDumpCommand`) prints it as
15
+ * JSON on stdout, and this adapter runs that subcommand as a subprocess
16
+ * and parses the output - no different in spirit from how ClickAdapter
17
+ * already shells out to a real Python interpreter instead of introspecting
18
+ * in-process.
19
+ *
20
+ * A real `cliguard-go` package doesn't exist yet (see the issue) - the
21
+ * target CLI's own author would today have to vendor a copy of
22
+ * examples/cobra-dump/clidump/dump.go directly and wire in
23
+ * `rootCmd.AddCommand(clidump.NewDumpCommand(rootCmd))` themselves. This
24
+ * adapter only proves the rest of the pattern (subprocess -> JSON ->
25
+ * Contract) actually works end to end against a real Cobra CLI.
26
+ *
27
+ * `entryPath` is either:
28
+ * - a compiled Cobra binary - run directly as `<entryPath> __cliguard_dump__`.
29
+ * - a `.go` file or a directory - run via `go run . __cliguard_dump__`
30
+ * (requires a `go` toolchain on PATH), which is how the example CLI
31
+ * under examples/cobra-dump is exercised without a separate build step.
32
+ */
33
+ class CobraAdapter {
34
+ constructor() {
35
+ this.id = "cobra";
36
+ this.limitations = [
37
+ "Proof of concept (see issue #9): there is no published `cliguard-go` package yet - the target CLI's own author must vendor an equivalent of examples/cobra-dump/clidump/dump.go and wire in `rootCmd.AddCommand(clidump.NewDumpCommand(rootCmd))` themselves, unlike every other adapter which needs zero changes to the target (Click included).",
38
+ "ArgumentContract is always [] - Cobra's Args validators (cobra.ExactArgs(1), cobra.MinimumNArgs(1), ...) carry no per-argument name or description, unlike Commander's .argument('<file>', ...).",
39
+ "CommandContract.description comes from Cobra's Short text only - Long is never read.",
40
+ 'OptionContract.valueType collapses every pflag type (string, int, stringSlice, ...) to "boolean" vs "string", the same simplification CacAdapter/YargsAdapter/ClickAdapter already make.',
41
+ "defaultValue is parsed from pflag's own DefValue string representation - correct for primitives and slices, but a flag whose default is itself a JSON-looking string could round-trip wrong.",
42
+ "A .go entry is run via `go run . <dump-subcommand>`, requiring a `go` toolchain on PATH - a compiled binary entry has no such requirement, matching how a real published cliguard-go integration would be used.",
43
+ "OptionContract.envVar is always undefined - Cobra/pflag itself has no built-in env var binding (that's normally layered on via viper, which this PoC's dump command doesn't wire in).",
44
+ ];
45
+ }
46
+ async extract(entryPath) {
47
+ const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
48
+ if (!(0, fs_1.existsSync)(absolutePath)) {
49
+ throw new Error(`cliguard: no such file or directory: "${absolutePath}".`);
50
+ }
51
+ const json = this.runDump(absolutePath);
52
+ return {
53
+ contractVersion: 1,
54
+ adapter: this.id,
55
+ capturedAt: new Date().toISOString(),
56
+ root: this.mapCommand(json),
57
+ };
58
+ }
59
+ runDump(absolutePath) {
60
+ const isDirectory = (0, fs_1.statSync)(absolutePath).isDirectory();
61
+ const isGoSource = isDirectory || (0, path_1.extname)(absolutePath) === ".go";
62
+ const result = isGoSource
63
+ ? (0, child_process_1.spawnSync)("go", ["run", ".", DUMP_SUBCOMMAND], {
64
+ cwd: isDirectory ? absolutePath : (0, path_1.dirname)(absolutePath),
65
+ encoding: "utf8",
66
+ })
67
+ : (0, child_process_1.spawnSync)(absolutePath, [DUMP_SUBCOMMAND], { encoding: "utf8" });
68
+ if (result.error) {
69
+ throw new Error(`cliguard: failed to run the Cobra target at "${absolutePath}": ${result.error.message}` +
70
+ (isGoSource ? ' - is a Go toolchain ("go") installed and on PATH?' : ""));
71
+ }
72
+ if (result.status !== 0) {
73
+ throw new Error(`cliguard: the Cobra target at "${absolutePath}" exited with code ${result.status} ` +
74
+ `running "${DUMP_SUBCOMMAND}" - does it call rootCmd.AddCommand(clidump.NewDumpCommand(rootCmd))? ` +
75
+ `(see examples/cobra-dump)\n${result.stderr.trim()}`);
76
+ }
77
+ try {
78
+ return JSON.parse(result.stdout);
79
+ }
80
+ catch {
81
+ throw new Error(`cliguard: internal error - the Cobra dump command's output wasn't valid JSON:\n${result.stdout}`);
82
+ }
83
+ }
84
+ mapCommand(json) {
85
+ return {
86
+ name: json.name,
87
+ description: json.short,
88
+ aliases: json.aliases,
89
+ options: json.flags.map((flag) => this.mapOption(flag)),
90
+ // See class limitations: Cobra's Args validators carry no per-argument metadata.
91
+ arguments: [],
92
+ subcommands: json.subcommands.map((sub) => this.mapCommand(sub)),
93
+ };
94
+ }
95
+ mapOption(flag) {
96
+ const valueType = flag.valueType === "bool" ? "boolean" : "string";
97
+ const isBoolean = valueType === "boolean";
98
+ const flags = (flag.shorthand ? `-${flag.shorthand}, ` : "") +
99
+ `--${flag.name}` +
100
+ (isBoolean ? "" : ` <${flag.name}>`);
101
+ return {
102
+ flags,
103
+ name: flag.name,
104
+ aliases: flag.shorthand ? [`-${flag.shorthand}`] : [],
105
+ description: flag.usage,
106
+ required: flag.required,
107
+ valueType,
108
+ variadic: /slice|array/i.test(flag.valueType),
109
+ defaultValue: this.parseDefault(flag.defValue, valueType),
110
+ };
111
+ }
112
+ parseDefault(raw, valueType) {
113
+ if (raw === "")
114
+ return null;
115
+ if (valueType === "boolean")
116
+ return raw === "true";
117
+ try {
118
+ return JSON.parse(raw);
119
+ }
120
+ catch {
121
+ return raw;
122
+ }
123
+ }
124
+ }
125
+ exports.CobraAdapter = CobraAdapter;
@@ -131,6 +131,10 @@ class CommanderAdapter {
131
131
  valueType: this.inferValueType(option.flags),
132
132
  variadic: option.variadic ?? false,
133
133
  defaultValue: option.defaultValue ?? null,
134
+ // Commander's own Option.envVar, set via `.env("NAME")` - undefined
135
+ // (not stored at all) when never called, matching OptionContract's
136
+ // own `envVar?:` shape.
137
+ envVar: option.envVar,
134
138
  };
135
139
  }
136
140
  /** `<value>` = required value, `[value]` = optional value, neither = boolean flag. Read from Commander's own flag declaration, not rendered --help text. */
@@ -0,0 +1,47 @@
1
+ import type { Contract } from "../core/types";
2
+ import type { CliAdapter } from "./adapter.interface";
3
+ /**
4
+ * Extracts a Contract from an oclif CLI project by reading its manifest -
5
+ * `oclif.manifest.json`, the same structured JSON oclif's own `oclif
6
+ * manifest` command produces (and which some oclif projects already commit,
7
+ * e.g. to speed up their own startup/README generation). Unlike Cobra or
8
+ * Click, no subprocess-based introspection script had to be written for
9
+ * this adapter: oclif already ships a first-class command that dumps a
10
+ * complete, versioned description of every command's flags and positional
11
+ * arguments as JSON - this adapter only has to run it (when needed) and map
12
+ * its shape onto `Contract`.
13
+ *
14
+ * `entryPath` is either:
15
+ * - a path to an already-generated `oclif.manifest.json` file - read directly, no subprocess.
16
+ * - a path to the oclif project's root directory (containing `package.json`)
17
+ * - if that directory already has its own `oclif.manifest.json` (common:
18
+ * some projects commit it, or a build step already produced it), that
19
+ * file is read as-is; otherwise this runs `npx oclif manifest .` there
20
+ * (requiring `oclif` installed as a devDependency) and removes the
21
+ * file it generated once done, leaving the project tree untouched.
22
+ *
23
+ * oclif's own command tree is flat, not nested the way Commander's is -
24
+ * every command has one `id` with `:` separating topic levels (e.g.
25
+ * `"config:get"`) rather than a real parent/child object graph. This
26
+ * adapter rebuilds the nested `CommandContract` tree `Contract` expects by
27
+ * splitting each id on `:`, synthesizing an empty topic node for any
28
+ * intermediate segment that isn't itself a real command (e.g. `"config"`
29
+ * when only `"config:get"` exists), matching how a real oclif CLI's own
30
+ * `--help` groups things.
31
+ */
32
+ export declare class OclifAdapter implements CliAdapter {
33
+ readonly id = "oclif";
34
+ readonly limitations: readonly string[];
35
+ extract(entryPath: string): Promise<Contract>;
36
+ private loadManifest;
37
+ private generateManifest;
38
+ private parseManifestFile;
39
+ /** Prefers the target's own package.json "name" (the CLI's real identity); falls back to a command's own `pluginName`, then a generic default - a manifest with zero commands has neither. */
40
+ private inferRootName;
41
+ private buildTree;
42
+ private makeNode;
43
+ private toCommandContract;
44
+ private mapFlags;
45
+ private mapFlag;
46
+ private mapArgs;
47
+ }
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OclifAdapter = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ /** The file `oclif manifest` writes - see https://oclif.io, the CLI's own "manifest" plugin command. */
8
+ const MANIFEST_FILENAME = "oclif.manifest.json";
9
+ /**
10
+ * Extracts a Contract from an oclif CLI project by reading its manifest -
11
+ * `oclif.manifest.json`, the same structured JSON oclif's own `oclif
12
+ * manifest` command produces (and which some oclif projects already commit,
13
+ * e.g. to speed up their own startup/README generation). Unlike Cobra or
14
+ * Click, no subprocess-based introspection script had to be written for
15
+ * this adapter: oclif already ships a first-class command that dumps a
16
+ * complete, versioned description of every command's flags and positional
17
+ * arguments as JSON - this adapter only has to run it (when needed) and map
18
+ * its shape onto `Contract`.
19
+ *
20
+ * `entryPath` is either:
21
+ * - a path to an already-generated `oclif.manifest.json` file - read directly, no subprocess.
22
+ * - a path to the oclif project's root directory (containing `package.json`)
23
+ * - if that directory already has its own `oclif.manifest.json` (common:
24
+ * some projects commit it, or a build step already produced it), that
25
+ * file is read as-is; otherwise this runs `npx oclif manifest .` there
26
+ * (requiring `oclif` installed as a devDependency) and removes the
27
+ * file it generated once done, leaving the project tree untouched.
28
+ *
29
+ * oclif's own command tree is flat, not nested the way Commander's is -
30
+ * every command has one `id` with `:` separating topic levels (e.g.
31
+ * `"config:get"`) rather than a real parent/child object graph. This
32
+ * adapter rebuilds the nested `CommandContract` tree `Contract` expects by
33
+ * splitting each id on `:`, synthesizing an empty topic node for any
34
+ * intermediate segment that isn't itself a real command (e.g. `"config"`
35
+ * when only `"config:get"` exists), matching how a real oclif CLI's own
36
+ * `--help` groups things.
37
+ */
38
+ class OclifAdapter {
39
+ constructor() {
40
+ this.id = "oclif";
41
+ this.limitations = [
42
+ 'CommandContract tree is rebuilt from oclif\'s flat, colon-separated command ids (e.g. "config:get") - an intermediate topic with no command of its own (e.g. "config" when only "config:get" exists) appears as an empty synthetic node purely to hold its children.',
43
+ "ArgumentContract.variadic is always false - oclif's manifest carries no per-argument multiple-values concept; a command declaring `static strict = false` to accept unlimited extra positional args doesn't surface those extra args as a named argument at all.",
44
+ 'OptionContract.valueType collapses every oclif flag kind (string, integer, url, ...) to "boolean" vs "string", the same simplification every adapter besides Commander already makes.',
45
+ "Requires generating (or reading an already-committed) oclif.manifest.json in the target project - when one doesn't already exist, this shells out to `npx oclif manifest .` (requiring `oclif` installed as a devDependency there) and removes the file it generated once done.",
46
+ "A single-command oclif CLI (package.json's `oclif.default`) may not appear as a distinct entry in the manifest at all, depending on the oclif version - verify with `cliguard doctor` before relying on it for that shape of CLI.",
47
+ ];
48
+ }
49
+ async extract(entryPath) {
50
+ const absolutePath = (0, path_1.resolve)(process.cwd(), entryPath);
51
+ if (!(0, fs_1.existsSync)(absolutePath)) {
52
+ throw new Error(`cliguard: no such file or directory: "${absolutePath}".`);
53
+ }
54
+ const manifest = this.loadManifest(absolutePath);
55
+ const rootName = this.inferRootName(absolutePath, manifest);
56
+ return {
57
+ contractVersion: 1,
58
+ adapter: this.id,
59
+ capturedAt: new Date().toISOString(),
60
+ root: this.buildTree(rootName, manifest),
61
+ };
62
+ }
63
+ loadManifest(absolutePath) {
64
+ if ((0, path_1.extname)(absolutePath) === ".json") {
65
+ return this.parseManifestFile(absolutePath);
66
+ }
67
+ const manifestPath = (0, path_1.join)(absolutePath, MANIFEST_FILENAME);
68
+ const alreadyExisted = (0, fs_1.existsSync)(manifestPath);
69
+ if (!alreadyExisted) {
70
+ this.generateManifest(absolutePath);
71
+ }
72
+ try {
73
+ return this.parseManifestFile(manifestPath);
74
+ }
75
+ finally {
76
+ // Only clean up a manifest this adapter itself produced as a
77
+ // side effect - one the target project already committed is left
78
+ // exactly as it was found.
79
+ if (!alreadyExisted) {
80
+ try {
81
+ (0, fs_1.rmSync)(manifestPath, { force: true });
82
+ }
83
+ catch {
84
+ // Best-effort cleanup - a stray oclif.manifest.json left behind
85
+ // is a minor annoyance, never worth failing extraction over.
86
+ }
87
+ }
88
+ }
89
+ }
90
+ generateManifest(projectDir) {
91
+ // `shell: true` only on win32, where `npx` itself is a `.cmd` shim
92
+ // that Node's spawnSync can't invoke directly (a well-known Windows
93
+ // gotcha - verified live: without it this fails with EINVAL/ENOENT
94
+ // regardless of PATH). Every argument here is a fixed literal, never
95
+ // interpolated from `projectDir` or any other dynamic value - it's
96
+ // passed via `cwd` instead - so shell mode carries no injection risk
97
+ // despite Node's own generic deprecation warning about it.
98
+ const result = (0, child_process_1.spawnSync)("npx", ["--no-install", "oclif", "manifest", "."], {
99
+ cwd: projectDir,
100
+ encoding: "utf8",
101
+ shell: process.platform === "win32",
102
+ });
103
+ if (result.error) {
104
+ throw new Error(`cliguard: failed to run \`oclif manifest\` in "${projectDir}": ${result.error.message} - ` +
105
+ 'is "oclif" installed as a devDependency there?');
106
+ }
107
+ if (result.status !== 0) {
108
+ throw new Error(`cliguard: \`oclif manifest\` exited with code ${result.status} in "${projectDir}" - ` +
109
+ 'is "oclif" installed as a devDependency, and does its package.json have a valid "oclif" field?\n' +
110
+ (result.stderr.trim() || result.stdout.trim()));
111
+ }
112
+ }
113
+ parseManifestFile(manifestPath) {
114
+ if (!(0, fs_1.existsSync)(manifestPath)) {
115
+ throw new Error(`cliguard: no such file: "${manifestPath}" - expected an oclif manifest. Run ` +
116
+ "`oclif manifest` in the plugin's root first, or point cliguard directly at that project directory.");
117
+ }
118
+ let raw;
119
+ try {
120
+ raw = (0, fs_1.readFileSync)(manifestPath, "utf8");
121
+ }
122
+ catch (error) {
123
+ throw new Error(`cliguard: could not read "${manifestPath}": ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ try {
126
+ return JSON.parse(raw);
127
+ }
128
+ catch {
129
+ throw new Error(`cliguard: internal error - "${manifestPath}" wasn't valid JSON.`);
130
+ }
131
+ }
132
+ /** Prefers the target's own package.json "name" (the CLI's real identity); falls back to a command's own `pluginName`, then a generic default - a manifest with zero commands has neither. */
133
+ inferRootName(absolutePath, manifest) {
134
+ const projectDir = (0, path_1.extname)(absolutePath) === ".json" ? (0, path_1.dirname)(absolutePath) : absolutePath;
135
+ const packageJsonPath = (0, path_1.join)(projectDir, "package.json");
136
+ if ((0, fs_1.existsSync)(packageJsonPath)) {
137
+ try {
138
+ const pkg = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, "utf8"));
139
+ if (typeof pkg.name === "string" && pkg.name)
140
+ return pkg.name;
141
+ }
142
+ catch {
143
+ // Falls through to the manifest-derived name below - an unreadable
144
+ // or malformed package.json alongside a perfectly good manifest
145
+ // shouldn't block extraction entirely.
146
+ }
147
+ }
148
+ const firstCommand = Object.values(manifest.commands)[0];
149
+ return firstCommand?.pluginName ?? "cli";
150
+ }
151
+ buildTree(rootName, manifest) {
152
+ const root = this.makeNode(rootName);
153
+ for (const [id, command] of Object.entries(manifest.commands)) {
154
+ const segments = id.split(":").filter((segment) => segment.length > 0);
155
+ let node = root;
156
+ for (const segment of segments) {
157
+ let child = node.children.get(segment);
158
+ if (!child) {
159
+ child = this.makeNode(segment);
160
+ node.children.set(segment, child);
161
+ }
162
+ node = child;
163
+ }
164
+ // `segments` is empty for a single-command CLI's own id (`""`) -
165
+ // its properties land directly on `root` in that case, same as any
166
+ // other command lands on the node its own path resolves to.
167
+ node.description = command.description ?? "";
168
+ node.aliases = [...(command.aliases ?? [])];
169
+ node.options = this.mapFlags(command.flags);
170
+ node.arguments = this.mapArgs(command.args);
171
+ }
172
+ return this.toCommandContract(root);
173
+ }
174
+ makeNode(name) {
175
+ return { name, description: "", aliases: [], options: [], arguments: [], children: new Map() };
176
+ }
177
+ toCommandContract(node) {
178
+ return {
179
+ name: node.name,
180
+ description: node.description,
181
+ aliases: node.aliases,
182
+ options: node.options,
183
+ arguments: node.arguments,
184
+ subcommands: [...node.children.values()].map((child) => this.toCommandContract(child)),
185
+ };
186
+ }
187
+ mapFlags(flags) {
188
+ return Object.entries(flags ?? {}).map(([name, flag]) => this.mapFlag(name, flag));
189
+ }
190
+ mapFlag(name, flag) {
191
+ const valueType = flag.type === "boolean" ? "boolean" : "string";
192
+ const long = `--${name}` + (valueType === "boolean" ? "" : ` <${name}>`);
193
+ return {
194
+ flags: flag.char ? `-${flag.char}, ${long}` : long,
195
+ name,
196
+ aliases: flag.char ? [`-${flag.char}`] : [],
197
+ description: flag.description ?? "",
198
+ required: flag.required ?? false,
199
+ valueType,
200
+ variadic: flag.multiple ?? false,
201
+ defaultValue: flag.default ?? null,
202
+ envVar: flag.env,
203
+ };
204
+ }
205
+ mapArgs(args) {
206
+ return Object.entries(args ?? {}).map(([name, arg]) => ({
207
+ name,
208
+ required: arg.required ?? false,
209
+ // See class limitations: oclif's manifest has no per-argument
210
+ // multiple-values concept.
211
+ variadic: false,
212
+ description: arg.description ?? "",
213
+ }));
214
+ }
215
+ }
216
+ exports.OclifAdapter = OclifAdapter;
@@ -4,7 +4,9 @@ exports.adapters = void 0;
4
4
  exports.resolveAdapter = resolveAdapter;
5
5
  const cac_adapter_1 = require("./cac.adapter");
6
6
  const click_adapter_1 = require("./click.adapter");
7
+ const cobra_adapter_1 = require("./cobra.adapter");
7
8
  const commander_adapter_1 = require("./commander.adapter");
9
+ const oclif_adapter_1 = require("./oclif.adapter");
8
10
  const yargs_adapter_1 = require("./yargs.adapter");
9
11
  // Constructing an adapter here is cheap (no eager require of its
10
12
  // framework - CacAdapter/YargsAdapter only load their framework lazily,
@@ -18,6 +20,8 @@ exports.adapters = {
18
20
  cac: new cac_adapter_1.CacAdapter(),
19
21
  yargs: new yargs_adapter_1.YargsAdapter(),
20
22
  click: new click_adapter_1.ClickAdapter(),
23
+ cobra: new cobra_adapter_1.CobraAdapter(),
24
+ oclif: new oclif_adapter_1.OclifAdapter(),
21
25
  };
22
26
  function resolveAdapter(name) {
23
27
  const adapter = exports.adapters[name];
@@ -89,6 +89,20 @@ export declare class YargsAdapter implements CliAdapter {
89
89
  * `arguments`, never duplicated into `options`.
90
90
  */
91
91
  private mapOptions;
92
+ /**
93
+ * Yargs has no *per-option* declared env var name (unlike Commander's
94
+ * `.env("NAME")`) - `.env(prefix)` instead turns on a blanket naming
95
+ * convention for every option at once, applied at real parse time by
96
+ * yargs-parser's own `applyEnvVars` (see yargs-parser's
97
+ * `yargs-parser.js`): an env var matching `<PREFIX_><NAME>` (uppercased,
98
+ * `-`/camelCase boundaries as `_`) satisfies the option named `<name>`
99
+ * unless the value was already supplied another way. This reconstructs
100
+ * that same name from the option side - the exact inverse of
101
+ * yargs-parser's own camelCase decoding - so it's a real, verified
102
+ * convention, not a guess. Returns `undefined` when `.env()` was never
103
+ * called, matching every other adapter's "no binding" shape.
104
+ */
105
+ private deriveEnvVar;
92
106
  private describe;
93
107
  /** `-x` for a single-character name, `--xray` otherwise - yargs's own alias lists carry neither dash. */
94
108
  private dashPrefix;
@@ -26,6 +26,7 @@ class YargsAdapter {
26
26
  this.id = "yargs";
27
27
  this.limitations = [
28
28
  "Each command's options are read from a fresh, isolated yargs instance built by re-running that command's builder, not the shared instance the target CLI actually built - see this class's own doc comment for why.",
29
+ "OptionContract.envVar is reconstructed from yargs's own `.env(prefix)` naming convention (PREFIX_OPTION_NAME), not read from a per-option declaration the way Commander's `.env(\"NAME\")` is - a name using yargs-parser's `__` nested-key separator won't round-trip correctly.",
29
30
  ];
30
31
  }
31
32
  async extract(entryPath) {
@@ -175,13 +176,18 @@ class YargsAdapter {
175
176
  const commandInstance = cli.getInternalMethods().getCommandInstance();
176
177
  const options = cli.getOptions();
177
178
  const descriptions = cli.getInternalMethods().getUsageInstance().getDescriptions();
179
+ // `.env()` is only ever set on the real, shared top-level instance -
180
+ // every subcommand's own options are read from a fresh, isolated
181
+ // instance further down (see mapCommand) that never had it called, so
182
+ // this is captured here once and threaded down explicitly instead.
183
+ const envPrefix = options.envPrefix;
178
184
  return {
179
185
  name: cli.$0,
180
186
  description: "",
181
187
  aliases: [],
182
- options: this.mapOptions(options, descriptions, new Set()),
188
+ options: this.mapOptions(options, descriptions, new Set(), envPrefix),
183
189
  arguments: [],
184
- subcommands: Object.entries(commandInstance.handlers).map(([name, handler]) => this.mapCommand(name, handler, commandInstance.aliasMap)),
190
+ subcommands: Object.entries(commandInstance.handlers).map(([name, handler]) => this.mapCommand(name, handler, commandInstance.aliasMap, envPrefix)),
185
191
  };
186
192
  }
187
193
  /**
@@ -194,7 +200,7 @@ class YargsAdapter {
194
200
  * CommanderAdapter/CacAdapter, neither of which surfaces their
195
201
  * framework's built-in help/version as a regular option either.
196
202
  */
197
- mapCommand(name, handler, parentAliasMap) {
203
+ mapCommand(name, handler, parentAliasMap, envPrefix) {
198
204
  const scoped = this.freshInstance();
199
205
  if (typeof handler.builder === "function") {
200
206
  handler.builder(scoped, false);
@@ -213,9 +219,9 @@ class YargsAdapter {
213
219
  aliases: Object.entries(parentAliasMap)
214
220
  .filter(([, canonical]) => canonical === name)
215
221
  .map(([alias]) => alias),
216
- options: this.mapOptions(options, descriptions, positionalNames),
222
+ options: this.mapOptions(options, descriptions, positionalNames, envPrefix),
217
223
  arguments: this.mapArguments(handler, descriptions),
218
- subcommands: Object.entries(commandInstance.handlers).map(([subName, subHandler]) => this.mapCommand(subName, subHandler, commandInstance.aliasMap)),
224
+ subcommands: Object.entries(commandInstance.handlers).map(([subName, subHandler]) => this.mapCommand(subName, subHandler, commandInstance.aliasMap, envPrefix)),
219
225
  };
220
226
  }
221
227
  freshInstance() {
@@ -247,7 +253,7 @@ class YargsAdapter {
247
253
  * internally) - excluded via `positionalNames` so they surface only in
248
254
  * `arguments`, never duplicated into `options`.
249
255
  */
250
- mapOptions(options, descriptions, positionalNames) {
256
+ mapOptions(options, descriptions, positionalNames, envPrefix) {
251
257
  const aliasTargets = new Set(Object.values(options.alias).flat());
252
258
  const allNames = new Set([
253
259
  ...options.boolean,
@@ -271,8 +277,32 @@ class YargsAdapter {
271
277
  valueType: this.inferValueType(options, name),
272
278
  variadic: options.array.includes(name),
273
279
  defaultValue: name in options.default ? options.default[name] : null,
280
+ envVar: this.deriveEnvVar(envPrefix, name),
274
281
  }));
275
282
  }
283
+ /**
284
+ * Yargs has no *per-option* declared env var name (unlike Commander's
285
+ * `.env("NAME")`) - `.env(prefix)` instead turns on a blanket naming
286
+ * convention for every option at once, applied at real parse time by
287
+ * yargs-parser's own `applyEnvVars` (see yargs-parser's
288
+ * `yargs-parser.js`): an env var matching `<PREFIX_><NAME>` (uppercased,
289
+ * `-`/camelCase boundaries as `_`) satisfies the option named `<name>`
290
+ * unless the value was already supplied another way. This reconstructs
291
+ * that same name from the option side - the exact inverse of
292
+ * yargs-parser's own camelCase decoding - so it's a real, verified
293
+ * convention, not a guess. Returns `undefined` when `.env()` was never
294
+ * called, matching every other adapter's "no binding" shape.
295
+ */
296
+ deriveEnvVar(envPrefix, name) {
297
+ if (envPrefix === undefined || envPrefix === false)
298
+ return undefined;
299
+ const prefix = typeof envPrefix === "string" ? envPrefix : "";
300
+ const decamelized = name
301
+ .replace(/-/g, "_")
302
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
303
+ .toUpperCase();
304
+ return prefix ? `${prefix}_${decamelized}` : decamelized;
305
+ }
276
306
  describe(descriptions, name) {
277
307
  const raw = descriptions[name] ?? "";
278
308
  return raw.startsWith(YARGS_STRING_MARKER) ? raw.slice(YARGS_STRING_MARKER.length) : raw;
package/dist/bin.js CHANGED
@@ -7,7 +7,9 @@ const registry_1 = require("./adapters/registry");
7
7
  const config_1 = require("./core/config");
8
8
  const diff_engine_1 = require("./core/diff.engine");
9
9
  const docs_1 = require("./core/docs");
10
+ const open_diff_1 = require("./core/open-diff");
10
11
  const report_formats_1 = require("./core/report-formats");
12
+ const webhook_1 = require("./core/webhook");
11
13
  const storage_1 = require("./core/storage");
12
14
  const types_1 = require("./core/types");
13
15
  const diffEngine = new diff_engine_1.DiffEngine();
@@ -122,8 +124,11 @@ program
122
124
  .option("--format <format>", `output format: ${REPORT_FORMATS.join(", ")}`)
123
125
  .option("--against <ref>", "compare against a git ref's committed contract (e.g. origin/main, a tag, a commit sha) instead of the .cliguard/contract.json on disk")
124
126
  .option("--strict", "enable extra rules for currently-silent risky changes (e.g. a positional argument reorder)", false)
127
+ .option("--webhook <url>", "POST the diff result as JSON to this URL after check runs (or set CLIGUARD_WEBHOOK_URL)")
128
+ .option("--open-diff", "on a real difference, write the expected/actual contracts to temp files and open them in your editor's diff view (falls back to printing the file paths if no supported editor is found)", false)
125
129
  .action(async (entry, options) => {
126
130
  const targets = resolveTargetsOrExit(entry, options.adapter);
131
+ const webhookUrl = options.webhook ?? process.env.CLIGUARD_WEBHOOK_URL;
127
132
  const exitCode = await runAcrossTargets(targets, (target) => withSuppressedExit(async () => {
128
133
  const format = resolveFormat(options.format, options.json);
129
134
  if (!format) {
@@ -137,6 +142,22 @@ program
137
142
  const diff = applyDeprecations(diffEngine.applyUnstableMarkers((0, config_1.applyConfig)(diffEngine.compare(oldContract, newContract, { strict: options.strict }), (0, config_1.loadConfig)()), oldContract, newContract), indexDeprecations((0, storage_1.readDeprecations)(target.namespace)));
138
143
  const acceptedPaths = indexAcceptedBreaks((0, storage_1.readAcceptedBreaks)(target.namespace));
139
144
  const hasBreaking = diff.some((change) => change.type === types_1.ChangeType.BREAKING && !acceptedPaths.has(change.path));
145
+ if (options.openDiff && diff.length > 0) {
146
+ const opened = (0, open_diff_1.openDiffInEditor)(oldContract, newContract, (0, open_diff_1.detectDiffTool)());
147
+ console.log(opened.openedWith
148
+ ? `🔍 --open-diff: opened ${opened.openedWith} --diff on the expected vs. actual contract.`
149
+ : "ℹ️ --open-diff: no supported editor found on PATH - contracts written to:\n" +
150
+ ` expected: ${opened.oldPath}\n` +
151
+ ` actual: ${opened.newPath}`);
152
+ }
153
+ if (webhookUrl) {
154
+ try {
155
+ await (0, webhook_1.postWebhook)(webhookUrl, (0, webhook_1.buildWebhookPayload)(target.entry, diff));
156
+ }
157
+ catch (error) {
158
+ console.error(error instanceof Error ? error.message : String(error));
159
+ }
160
+ }
140
161
  if (format !== "text") {
141
162
  console.log(formatReport(diff, acceptedPaths, format, (0, storage_1.getContractDisplayPath)(target.namespace)));
142
163
  return hasBreaking ? 1 : 0;
@@ -66,6 +66,17 @@ export declare class DiffEngine {
66
66
  private commandLabel;
67
67
  private compareOptions;
68
68
  private compareOption;
69
+ /**
70
+ * `envVar` is a single optional binding (unlike `aliases`, a set), so it
71
+ * gets its own three-way comparison rather than reusing `compareAliases`:
72
+ * losing the *only* way an env var could satisfy this flag is BREAKING
73
+ * (an existing invocation that only ever set the env var, never the flag
74
+ * itself, silently stops working), gaining one is purely ADDITIVE (every
75
+ * existing invocation keeps working exactly as before), and renaming it
76
+ * is BREAKING too - from a caller's perspective that's the same as
77
+ * losing the old binding, even though a new one appears in its place.
78
+ */
79
+ private compareEnvVar;
69
80
  private compareArguments;
70
81
  /**
71
82
  * `--strict`-only: positional arguments are matched by name everywhere
@@ -235,6 +235,7 @@ class DiffEngine {
235
235
  });
236
236
  }
237
237
  results.push(...this.compareAliases(oldOption.aliases, newOption.aliases, path, label));
238
+ results.push(...this.compareEnvVar(oldOption.envVar, newOption.envVar, path, label));
238
239
  if (oldOption.description !== newOption.description) {
239
240
  results.push({
240
241
  type: types_1.ChangeType.PATCH,
@@ -244,6 +245,45 @@ class DiffEngine {
244
245
  }
245
246
  return results;
246
247
  }
248
+ /**
249
+ * `envVar` is a single optional binding (unlike `aliases`, a set), so it
250
+ * gets its own three-way comparison rather than reusing `compareAliases`:
251
+ * losing the *only* way an env var could satisfy this flag is BREAKING
252
+ * (an existing invocation that only ever set the env var, never the flag
253
+ * itself, silently stops working), gaining one is purely ADDITIVE (every
254
+ * existing invocation keeps working exactly as before), and renaming it
255
+ * is BREAKING too - from a caller's perspective that's the same as
256
+ * losing the old binding, even though a new one appears in its place.
257
+ */
258
+ compareEnvVar(oldEnvVar, newEnvVar, path, label) {
259
+ if (oldEnvVar === newEnvVar)
260
+ return [];
261
+ if (oldEnvVar && !newEnvVar) {
262
+ return [
263
+ {
264
+ type: types_1.ChangeType.BREAKING,
265
+ path,
266
+ message: `${label} no longer reads from environment variable "${oldEnvVar}" - an invocation relying on that env var instead of the flag itself will silently stop working.`,
267
+ },
268
+ ];
269
+ }
270
+ if (!oldEnvVar && newEnvVar) {
271
+ return [
272
+ {
273
+ type: types_1.ChangeType.ADDITIVE,
274
+ path,
275
+ message: `${label} can now also be set via environment variable "${newEnvVar}".`,
276
+ },
277
+ ];
278
+ }
279
+ return [
280
+ {
281
+ type: types_1.ChangeType.BREAKING,
282
+ path,
283
+ message: `${label} environment variable binding changed from "${oldEnvVar}" to "${newEnvVar}" - an invocation relying on "${oldEnvVar}" will silently stop working.`,
284
+ },
285
+ ];
286
+ }
247
287
  compareArguments(oldArgs, newArgs, path, options) {
248
288
  const results = [];
249
289
  const oldByName = this.indexByName(oldArgs);
@@ -0,0 +1,39 @@
1
+ import type { Contract } from "./types";
2
+ /**
3
+ * `cliguard check --open-diff`'s local equivalent of ApprovalTests'
4
+ * reporters, which open an external diff tool the moment a test fails -
5
+ * here, the moment a real contract difference is found. Deliberately
6
+ * simple: one known editor (VS Code's own `code` CLI, which ships a
7
+ * built-in two-pane `--diff` view) probed for on PATH, with a plain
8
+ * "here are the file paths" fallback when it isn't there - never a hard
9
+ * failure, and never anything that changes `check`'s own exit code.
10
+ */
11
+ export interface DiffTool {
12
+ readonly command: string;
13
+ readonly buildArgs: (oldPath: string, newPath: string) => string[];
14
+ }
15
+ /**
16
+ * True if `command --version` runs successfully - the simplest portable
17
+ * "is this on PATH" probe, without depending on `which`/`where` (neither
18
+ * of which exists on every platform cliguard runs on).
19
+ */
20
+ export declare function isCommandAvailable(command: string): boolean;
21
+ /** `isAvailable` is injectable purely so tests can exercise both branches without depending on whether a real editor happens to be on the test runner's own PATH. */
22
+ export declare function detectDiffTool(tools?: readonly DiffTool[], isAvailable?: (command: string) => boolean): DiffTool | undefined;
23
+ export interface OpenDiffResult {
24
+ readonly oldPath: string;
25
+ readonly newPath: string;
26
+ /** The diff tool's own command name, present only if one was actually found and launched. */
27
+ readonly openedWith?: string;
28
+ }
29
+ /**
30
+ * Writes both contracts to a fresh temp directory as pretty-printed JSON,
31
+ * then - if `tool` is given (the caller's own `detectDiffTool()` result,
32
+ * not defaulted here so a test can pass `undefined` and mean it) - opens
33
+ * them there, detached from cliguard's own process so `check` still exits
34
+ * immediately with its normal code instead of waiting on the editor
35
+ * window to close. Never throws: a tool that fails to actually launch
36
+ * still leaves both files on disk, which is all the caller falls back to
37
+ * printing anyway.
38
+ */
39
+ export declare function openDiffInEditor(oldContract: Contract, newContract: Contract, tool: DiffTool | undefined): OpenDiffResult;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isCommandAvailable = isCommandAvailable;
4
+ exports.detectDiffTool = detectDiffTool;
5
+ exports.openDiffInEditor = openDiffInEditor;
6
+ const child_process_1 = require("child_process");
7
+ const fs_1 = require("fs");
8
+ const os_1 = require("os");
9
+ const path_1 = require("path");
10
+ const DIFF_TOOLS = [
11
+ { command: "code", buildArgs: (a, b) => ["--diff", a, b] },
12
+ ];
13
+ /**
14
+ * True if `command --version` runs successfully - the simplest portable
15
+ * "is this on PATH" probe, without depending on `which`/`where` (neither
16
+ * of which exists on every platform cliguard runs on).
17
+ */
18
+ function isCommandAvailable(command) {
19
+ const result = (0, child_process_1.spawnSync)(command, ["--version"], {
20
+ encoding: "utf8",
21
+ shell: process.platform === "win32",
22
+ });
23
+ return !result.error && result.status === 0;
24
+ }
25
+ /** `isAvailable` is injectable purely so tests can exercise both branches without depending on whether a real editor happens to be on the test runner's own PATH. */
26
+ function detectDiffTool(tools = DIFF_TOOLS, isAvailable = isCommandAvailable) {
27
+ return tools.find((tool) => isAvailable(tool.command));
28
+ }
29
+ /**
30
+ * Writes both contracts to a fresh temp directory as pretty-printed JSON,
31
+ * then - if `tool` is given (the caller's own `detectDiffTool()` result,
32
+ * not defaulted here so a test can pass `undefined` and mean it) - opens
33
+ * them there, detached from cliguard's own process so `check` still exits
34
+ * immediately with its normal code instead of waiting on the editor
35
+ * window to close. Never throws: a tool that fails to actually launch
36
+ * still leaves both files on disk, which is all the caller falls back to
37
+ * printing anyway.
38
+ */
39
+ function openDiffInEditor(oldContract, newContract, tool) {
40
+ const dir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "cliguard-diff-"));
41
+ const oldPath = (0, path_1.join)(dir, "expected.contract.json");
42
+ const newPath = (0, path_1.join)(dir, "actual.contract.json");
43
+ (0, fs_1.writeFileSync)(oldPath, JSON.stringify(oldContract, null, 2) + "\n");
44
+ (0, fs_1.writeFileSync)(newPath, JSON.stringify(newContract, null, 2) + "\n");
45
+ if (!tool)
46
+ return { oldPath, newPath };
47
+ try {
48
+ const child = (0, child_process_1.spawn)(tool.command, tool.buildArgs(oldPath, newPath), {
49
+ detached: true,
50
+ stdio: "ignore",
51
+ shell: process.platform === "win32",
52
+ });
53
+ // A launch failure surfacing asynchronously (after this function has
54
+ // already returned "openedWith") is still not fatal - both files are
55
+ // already safely on disk regardless - so this only exists to stop
56
+ // Node from ever treating an unhandled 'error' event as a crash.
57
+ child.on("error", () => undefined);
58
+ child.unref();
59
+ return { oldPath, newPath, openedWith: tool.command };
60
+ }
61
+ catch {
62
+ return { oldPath, newPath };
63
+ }
64
+ }
@@ -22,6 +22,21 @@ export interface OptionContract {
22
22
  readonly variadic: boolean;
23
23
  /** JSON-serializable default, or null if the framework declared none. */
24
24
  readonly defaultValue: unknown;
25
+ /**
26
+ * Name of the environment variable that can also satisfy this flag (e.g.
27
+ * Commander's `.env("BUILD_TARGET")`, Click's `envvar="BUILD_TARGET"`,
28
+ * yargs's `.env(prefix)` convention), or `undefined` if the framework
29
+ * declared none - never guessed from a description or naming convention
30
+ * the framework itself doesn't actually apply. A maintainer renaming or
31
+ * removing this binding is a real, otherwise-invisible breaking change:
32
+ * existing invocations that rely on the env var (and never pass the
33
+ * flag directly) silently stop working. Omitted entirely (rather than
34
+ * `null`) for a framework/option that has no such binding, matching how
35
+ * TypeScript's own `?:` already distinguishes "never applicable" from
36
+ * "explicitly none" - unlike `defaultValue`, which every option always
37
+ * has an answer for (even if that answer is "none").
38
+ */
39
+ readonly envVar?: string;
25
40
  }
26
41
  export interface ArgumentContract {
27
42
  /** Positional argument name, e.g. "file" from "<file>" or "[file]". */
@@ -0,0 +1,27 @@
1
+ import type { DiffResult } from "./diff.engine";
2
+ /** The same three fields DiffResult carries on the wire - `removal` is an internal detail (used by `cliguard deprecate`), not part of the v1 webhook payload. */
3
+ export interface WebhookChange {
4
+ readonly type: string;
5
+ readonly path: string;
6
+ readonly message: string;
7
+ }
8
+ export interface WebhookPayload {
9
+ /** The entry file (or config target name) `check` ran against. */
10
+ readonly entry: string;
11
+ /** `git config --get remote.origin.url`, or null outside a git repo / with no origin configured. */
12
+ readonly repo: string | null;
13
+ /** `git rev-parse HEAD`, or null outside a git repository. */
14
+ readonly commit: string | null;
15
+ readonly changes: readonly WebhookChange[];
16
+ }
17
+ export declare function buildWebhookPayload(entry: string, changes: readonly DiffResult[]): WebhookPayload;
18
+ /**
19
+ * POSTs a check result to a configurable webhook URL - v1 of the "SaaS
20
+ * integration" building block from the README's roadmap (issue #3): just
21
+ * the POST itself, no receiving service or dashboard. Uses Node's native
22
+ * `fetch` (18+) - no new dependency. Throws on a network error or a non-2xx
23
+ * response so the caller (bin.ts) decides how to surface that; it never
24
+ * affects `check`'s own exit code, which reflects the CLI contract diff,
25
+ * not whether a webhook happened to be reachable.
26
+ */
27
+ export declare function postWebhook(url: string, payload: WebhookPayload): Promise<void>;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildWebhookPayload = buildWebhookPayload;
4
+ exports.postWebhook = postWebhook;
5
+ const child_process_1 = require("child_process");
6
+ /** Best-effort: a plain temp dir, a shallow clone with no origin, or no git at all should never fail the payload - just leaves the field null. */
7
+ function gitValue(args) {
8
+ try {
9
+ const out = (0, child_process_1.execFileSync)("git", args, {
10
+ encoding: "utf-8",
11
+ stdio: ["ignore", "pipe", "pipe"],
12
+ }).trim();
13
+ return out.length > 0 ? out : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function buildWebhookPayload(entry, changes) {
20
+ return {
21
+ entry,
22
+ repo: gitValue(["config", "--get", "remote.origin.url"]),
23
+ commit: gitValue(["rev-parse", "HEAD"]),
24
+ changes: changes.map(({ type, path, message }) => ({ type, path, message })),
25
+ };
26
+ }
27
+ /**
28
+ * POSTs a check result to a configurable webhook URL - v1 of the "SaaS
29
+ * integration" building block from the README's roadmap (issue #3): just
30
+ * the POST itself, no receiving service or dashboard. Uses Node's native
31
+ * `fetch` (18+) - no new dependency. Throws on a network error or a non-2xx
32
+ * response so the caller (bin.ts) decides how to surface that; it never
33
+ * affects `check`'s own exit code, which reflects the CLI contract diff,
34
+ * not whether a webhook happened to be reachable.
35
+ */
36
+ async function postWebhook(url, payload) {
37
+ let response;
38
+ try {
39
+ response = await fetch(url, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json" },
42
+ body: JSON.stringify(payload),
43
+ // Never lets an unreachable/slow webhook host hang `check` - 5s is
44
+ // generous for a same-request JSON POST, and a timeout is reported
45
+ // through the same catch below as any other network failure.
46
+ signal: AbortSignal.timeout(5000),
47
+ });
48
+ }
49
+ catch (error) {
50
+ throw new Error(`cliguard: webhook POST to "${url}" failed: ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+ if (!response.ok) {
53
+ throw new Error(`cliguard: webhook POST to "${url}" failed with status ${response.status}.`);
54
+ }
55
+ }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,9 @@ import type { Contract } from "./core/types";
3
3
  export type { CliAdapter } from "./adapters/adapter.interface";
4
4
  export { CacAdapter } from "./adapters/cac.adapter";
5
5
  export { ClickAdapter } from "./adapters/click.adapter";
6
+ export { CobraAdapter } from "./adapters/cobra.adapter";
6
7
  export { CommanderAdapter } from "./adapters/commander.adapter";
8
+ export { OclifAdapter } from "./adapters/oclif.adapter";
7
9
  export { YargsAdapter } from "./adapters/yargs.adapter";
8
10
  export { adapters, resolveAdapter } from "./adapters/registry";
9
11
  export { applyConfig, configExists, loadConfig } from "./core/config";
@@ -29,5 +31,5 @@ export declare function extractContract(entryPath: string, adapterName?: string)
29
31
  * or a git ref by the caller's own code.
30
32
  */
31
33
  export declare function compareContracts(oldContract: Contract, newContract: Contract, options?: CompareOptions): DiffResult[];
32
- /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click"]. */
34
+ /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click", "cobra", "oclif"] - "cobra" is a proof of concept, see src/adapters/cobra.adapter.ts. */
33
35
  export declare function listAdapters(): string[];
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ChangeType = exports.toRdjsonl = exports.toJUnitXml = exports.toGitLabCodeQuality = exports.renderMarkdownDocs = exports.DiffEngine = exports.loadConfig = exports.configExists = exports.applyConfig = exports.resolveAdapter = exports.adapters = exports.YargsAdapter = exports.CommanderAdapter = exports.ClickAdapter = exports.CacAdapter = void 0;
3
+ exports.ChangeType = exports.toRdjsonl = exports.toJUnitXml = exports.toGitLabCodeQuality = exports.renderMarkdownDocs = exports.DiffEngine = exports.loadConfig = exports.configExists = exports.applyConfig = exports.resolveAdapter = exports.adapters = exports.YargsAdapter = exports.OclifAdapter = exports.CommanderAdapter = exports.CobraAdapter = exports.ClickAdapter = exports.CacAdapter = void 0;
4
4
  exports.extractContract = extractContract;
5
5
  exports.compareContracts = compareContracts;
6
6
  exports.listAdapters = listAdapters;
@@ -18,8 +18,12 @@ var cac_adapter_1 = require("./adapters/cac.adapter");
18
18
  Object.defineProperty(exports, "CacAdapter", { enumerable: true, get: function () { return cac_adapter_1.CacAdapter; } });
19
19
  var click_adapter_1 = require("./adapters/click.adapter");
20
20
  Object.defineProperty(exports, "ClickAdapter", { enumerable: true, get: function () { return click_adapter_1.ClickAdapter; } });
21
+ var cobra_adapter_1 = require("./adapters/cobra.adapter");
22
+ Object.defineProperty(exports, "CobraAdapter", { enumerable: true, get: function () { return cobra_adapter_1.CobraAdapter; } });
21
23
  var commander_adapter_1 = require("./adapters/commander.adapter");
22
24
  Object.defineProperty(exports, "CommanderAdapter", { enumerable: true, get: function () { return commander_adapter_1.CommanderAdapter; } });
25
+ var oclif_adapter_1 = require("./adapters/oclif.adapter");
26
+ Object.defineProperty(exports, "OclifAdapter", { enumerable: true, get: function () { return oclif_adapter_1.OclifAdapter; } });
23
27
  var yargs_adapter_1 = require("./adapters/yargs.adapter");
24
28
  Object.defineProperty(exports, "YargsAdapter", { enumerable: true, get: function () { return yargs_adapter_1.YargsAdapter; } });
25
29
  var registry_2 = require("./adapters/registry");
@@ -64,7 +68,7 @@ async function extractContract(entryPath, adapterName = "commander") {
64
68
  function compareContracts(oldContract, newContract, options) {
65
69
  return diffEngine.compare(oldContract, newContract, options);
66
70
  }
67
- /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click"]. */
71
+ /** Every adapter name `extractContract`/the CLI's `--adapter` flag will accept, e.g. ["commander", "cac", "yargs", "click", "cobra", "oclif"] - "cobra" is a proof of concept, see src/adapters/cobra.adapter.ts. */
68
72
  function listAdapters() {
69
73
  return Object.keys(registry_1.adapters);
70
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliguard",
3
- "version": "0.7.3",
3
+ "version": "0.8.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": [
6
6
  "cli",
@@ -9,6 +9,7 @@
9
9
  "commander",
10
10
  "yargs",
11
11
  "click",
12
+ "oclif",
12
13
  "ci"
13
14
  ],
14
15
  "author": "Bryandero98",