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 +61 -6
- package/dist/adapters/cac.adapter.js +1 -0
- package/dist/adapters/click.adapter.js +14 -0
- package/dist/adapters/cobra.adapter.d.ts +35 -0
- package/dist/adapters/cobra.adapter.js +125 -0
- package/dist/adapters/commander.adapter.js +4 -0
- package/dist/adapters/oclif.adapter.d.ts +47 -0
- package/dist/adapters/oclif.adapter.js +216 -0
- package/dist/adapters/registry.js +4 -0
- package/dist/adapters/yargs.adapter.d.ts +14 -0
- package/dist/adapters/yargs.adapter.js +36 -6
- package/dist/bin.js +21 -0
- package/dist/core/diff.engine.d.ts +11 -0
- package/dist/core/diff.engine.js +40 -0
- package/dist/core/open-diff.d.ts +39 -0
- package/dist/core/open-diff.js +64 -0
- package/dist/core/types.d.ts +15 -0
- package/dist/core/webhook.d.ts +27 -0
- package/dist/core/webhook.js +55 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +6 -2
- package/package.json +2 -1
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
|
-
|
|
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@
|
|
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@
|
|
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`),
|
|
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
|
|
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
|
package/dist/core/diff.engine.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|