@mirasen/license-gate 1.0.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +157 -0
- package/dist/commands/_shared.d.ts +17 -0
- package/dist/commands/_shared.js +33 -0
- package/dist/commands/check.d.ts +20 -0
- package/dist/commands/check.js +51 -0
- package/dist/commands/collect.d.ts +24 -0
- package/dist/commands/collect.js +31 -0
- package/dist/graph/load.d.ts +17 -0
- package/dist/graph/load.js +35 -0
- package/dist/graph/narrow.d.ts +24 -0
- package/dist/graph/narrow.js +114 -0
- package/dist/graph/record.d.ts +38 -0
- package/dist/graph/record.js +112 -0
- package/dist/graph/scope.d.ts +22 -0
- package/dist/graph/scope.js +88 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- package/dist/policy/allowed-hard.d.ts +17 -0
- package/dist/policy/allowed-hard.js +27 -0
- package/dist/policy/allowed-packages.d.ts +29 -0
- package/dist/policy/allowed-packages.js +137 -0
- package/dist/policy/evaluate.d.ts +30 -0
- package/dist/policy/evaluate.js +151 -0
- package/dist/policy/spdx-shape.d.ts +34 -0
- package/dist/policy/spdx-shape.js +76 -0
- package/dist/report/human.d.ts +12 -0
- package/dist/report/human.js +117 -0
- package/dist/report/json.d.ts +27 -0
- package/dist/report/json.js +53 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +19 -0
- package/package.json +89 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @mirasen/license-gate
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 6169add: Initial implementation of `@mirasen/license-gate` — a strict, local, default-deny license
|
|
8
|
+
policy gate for npm projects (single-package, workspaces, monorepos).
|
|
9
|
+
- Two CLI commands: `license-gate check` (gate the installed graph against
|
|
10
|
+
`licenses/allowed-hard.txt` and `licenses/allowed-packages.txt`) and `license-gate
|
|
11
|
+
collect` (enumerate installed package/license metadata, no policy I/O).
|
|
12
|
+
- Project root is always `process.cwd()` — no walk-up rescue, no `--cwd`.
|
|
13
|
+
- Graph discovery via `@npmcli/arborist.loadActual()` + `tree.inventory`. Captures
|
|
14
|
+
non-hoisted workspace installs and transitive duplicates.
|
|
15
|
+
- `--workspace <name|path>` narrows the evaluation set via reachability over
|
|
16
|
+
`node.edgesOut` (never by realpath prefix, never by re-pointing Arborist).
|
|
17
|
+
- License detection is non-inferential: only a non-empty string `package.json#license`
|
|
18
|
+
is read. Object/array shapes and missing fields map to `could not determine`.
|
|
19
|
+
- Literal-first matching: the full license string is searched in `allowed-hard.txt`
|
|
20
|
+
before any SPDX parsing.
|
|
21
|
+
- SPDX boolean shape (OR/AND/parens) is supported via `spdx-expression-parse`. Each
|
|
22
|
+
leaf — including WITH-exception leaves rendered as `"<license-id> WITH <exception-id>"`
|
|
23
|
+
composites — is a literal allowlist lookup. No normalisation, no `spdx-correct`, no
|
|
24
|
+
`spdx-satisfies`.
|
|
25
|
+
- Two top-level violation reasons (`license-not-in-allowlist`,
|
|
26
|
+
`package-not-in-allowlist`) with two diagnostic detail codes
|
|
27
|
+
(`literal-not-allowed-and-spdx-unparseable`, `spdx-expression-not-satisfied`).
|
|
28
|
+
- `check --out` is rejected with exit 2; output flags are `check --json <path>` and
|
|
29
|
+
`collect --out <path>` / `collect --json <path>`.
|
|
30
|
+
- Programmatic API exported from `@mirasen/license-gate` (`runCheck`, `runCollect`).
|
|
31
|
+
- Engines tightened to Node ≥22.9.0.
|
|
32
|
+
- Public package records (`InstalledPackageRecord`, `CollectedRecord`, every JSON
|
|
33
|
+
decision/record emitted by `check --json` / `collect --json`) intentionally do NOT
|
|
34
|
+
carry a `workspace` field. Physical placement of an installed copy (hoisted vs.
|
|
35
|
+
workspace-local) is communicated entirely through the relative `path` field —
|
|
36
|
+
`node_modules/foo` for hoisted deps, `apps/web/node_modules/foo` for non-hoisted
|
|
37
|
+
workspace-local copies. The `--workspace <name|path>` CLI flag still narrows the
|
|
38
|
+
evaluation set to a workspace's reachable graph; that behavior is unchanged.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 [Contributors](https://github.com/mirasen-io/license-gate/graphs/contributors)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/@mirasen/license-gate)
|
|
2
|
+
[](https://github.com/mirasen-io/license-gate/actions/workflows/ci.yml)
|
|
3
|
+
[](https://sonarcloud.io/summary/new_code?id=mirasen-io_license-gate)
|
|
4
|
+
[](https://sonarcloud.io/summary/new_code?id=mirasen-io_license-gate)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
# @mirasen/license-gate
|
|
8
|
+
|
|
9
|
+
A strict, local, default-deny license policy gate for npm projects — single-package, npm
|
|
10
|
+
workspaces, and monorepos.
|
|
11
|
+
|
|
12
|
+
`license-gate` reads your installed dependency graph (via `@npmcli/arborist`), checks every
|
|
13
|
+
package against a literal license allowlist you control, and exits non-zero when anything
|
|
14
|
+
fails. It is intentionally small and refuses to guess.
|
|
15
|
+
|
|
16
|
+
It is a strict alternative to generic npm license checkers: instead of guessing licenses
|
|
17
|
+
or hiding exceptions, it enforces your local policy file and shows every override.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -D @mirasen/license-gate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires **Node ≥ 22.9.0**.
|
|
26
|
+
|
|
27
|
+
Run `license-gate` after `npm ci` (or after any install that produces the `node_modules`
|
|
28
|
+
layout you intend to ship). The gate evaluates what is **physically installed**.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Full project graph (project root = process.cwd())
|
|
34
|
+
npx license-gate check
|
|
35
|
+
|
|
36
|
+
# Run from any directory; --cwd selects the project root explicitly
|
|
37
|
+
npx license-gate check --cwd /path/to/project
|
|
38
|
+
|
|
39
|
+
# Narrow to one workspace
|
|
40
|
+
npx license-gate check --workspace apps/web
|
|
41
|
+
|
|
42
|
+
# Combine: explicit project root + workspace narrow
|
|
43
|
+
npx license-gate check --cwd /path/to/monorepo --workspace apps/web
|
|
44
|
+
|
|
45
|
+
# Enumerate all installed packages with their licenses (no policy I/O)
|
|
46
|
+
npx license-gate collect
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
You can also invoke without local install:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @mirasen/license-gate check
|
|
53
|
+
npx @mirasen/license-gate collect
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or as an npm script:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"scripts": {
|
|
61
|
+
"lint:licenses": "license-gate check"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Commands
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
license-gate check [--cwd <path>] [--workspace <name|path>] [--json <path>]
|
|
70
|
+
license-gate collect [--cwd <path>] [--workspace <name|path>] [--out <path>] [--json <path>]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`check` requires `licenses/allowed-hard.txt` at the project root. `collect` does not.
|
|
74
|
+
|
|
75
|
+
### Project root
|
|
76
|
+
|
|
77
|
+
The project root is `--cwd <path>` if provided, otherwise `process.cwd()`. `package.json`
|
|
78
|
+
must exist directly at that path — `license-gate` does **not** walk upward to find a
|
|
79
|
+
parent project. All project-relative paths (`licenses/allowed-hard.txt`,
|
|
80
|
+
`licenses/allowed-packages.txt`, `--workspace` relative paths, `--out`, `--json`) resolve
|
|
81
|
+
against the project root.
|
|
82
|
+
|
|
83
|
+
When `--workspace` is used together with `--cwd`, Arborist still loads the graph at the
|
|
84
|
+
project root (never at the workspace path); narrowing is performed inside that already
|
|
85
|
+
loaded tree.
|
|
86
|
+
|
|
87
|
+
### Exit codes
|
|
88
|
+
|
|
89
|
+
| Code | Meaning |
|
|
90
|
+
| ---- | ------------------------------------------------------ |
|
|
91
|
+
| `0` | clean: no violations (`check`) / completed (`collect`) |
|
|
92
|
+
| `1` | one or more policy violations |
|
|
93
|
+
| `2` | usage / config / runtime error |
|
|
94
|
+
|
|
95
|
+
`check` always collects every violation before exiting; it never fails fast.
|
|
96
|
+
|
|
97
|
+
## Policy files
|
|
98
|
+
|
|
99
|
+
`license-gate` reads two fixed files relative to the **selected project root**
|
|
100
|
+
(`--cwd` if provided, otherwise `process.cwd()`):
|
|
101
|
+
|
|
102
|
+
- `licenses/allowed-hard.txt` — **required for `check`**. The literal license allowlist.
|
|
103
|
+
- `licenses/allowed-packages.txt` — optional. Visible package overrides.
|
|
104
|
+
|
|
105
|
+
These paths are not configurable. There is no `--allowed`, no `package.json` config, no
|
|
106
|
+
environment variable. A configurable hard gate is a relaxable hard gate.
|
|
107
|
+
|
|
108
|
+
### `licenses/allowed-hard.txt`
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
# One literal accepted license string per line.
|
|
112
|
+
# Blank lines and `#` comments are ignored. No regex, no glob, no normalisation.
|
|
113
|
+
|
|
114
|
+
MIT
|
|
115
|
+
Apache-2.0
|
|
116
|
+
BSD-3-Clause
|
|
117
|
+
ISC
|
|
118
|
+
0BSD
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A package's `license` string must appear **verbatim** on a line above. SPDX expressions
|
|
122
|
+
(`(MIT OR Apache-2.0)`, `(MIT AND BSD-3-Clause)`) are evaluated literally per leaf — each
|
|
123
|
+
leaf must appear verbatim above.
|
|
124
|
+
|
|
125
|
+
### `licenses/allowed-packages.txt`
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
# Package overrides. Five forms accepted, anything else is a config error (exit 2):
|
|
129
|
+
#
|
|
130
|
+
# @scope/* trusted internal namespace
|
|
131
|
+
# package-name@version exact installed unscoped package version
|
|
132
|
+
# @scope/package@version exact installed scoped package version
|
|
133
|
+
# package-name@* any installed version of exact unscoped package
|
|
134
|
+
# @scope/package@* any installed version of exact scoped package
|
|
135
|
+
|
|
136
|
+
@mirasen/*
|
|
137
|
+
internal-tool@1.2.3
|
|
138
|
+
@types/node@22.0.0
|
|
139
|
+
spawndamnit@*
|
|
140
|
+
@scope/weird-package@*
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Override matches are **always visible** in the report (`matchedPackageRule`); they are
|
|
144
|
+
never silent excludes. When several rules match the same package, the most specific rule
|
|
145
|
+
wins in the audit trail, with this strict precedence: license-allow first, then exact
|
|
146
|
+
`package@version`, then `package@*` / `@scope/package@*`, then `@scope/*`. When a package
|
|
147
|
+
is in `allowed-hard.txt`, `allowed-by-license` wins — overrides are escape hatches, not
|
|
148
|
+
the default story.
|
|
149
|
+
|
|
150
|
+
**When to use which form** (most-precise to least-precise, prefer the narrower form):
|
|
151
|
+
|
|
152
|
+
- `package-name@version` / `@scope/package@version` — highest precision; pin exactly
|
|
153
|
+
one version of one package. Use this for one-off, manually-reviewed packages whose
|
|
154
|
+
license terms you accept at a specific version.
|
|
155
|
+
- `package-name@*` / `@scope/package@*` — same package, any version. Use this when you
|
|
156
|
+
intentionally approve the package at the package-name level rather than pinning every
|
|
157
|
+
installed version, for example to avoid repeated Dependabot allowlist edits after
|
|
158
|
+
routine version bumps. **Not** the default first choice — prefer the exact-version
|
|
159
|
+
form unless version bumps would routinely force allowlist edits.
|
|
160
|
+
- `@scope/*` — broadest. Reserve for trusted internal namespaces such as `@mirasen/*`.
|
|
161
|
+
Do not use for unrelated third-party packages that happen to share a namespace.
|
|
162
|
+
|
|
163
|
+
All five forms are escape hatches and remain audit-visible. The JSON report's
|
|
164
|
+
`matchedPackageRule` field carries the matching rule verbatim so reviewers can see at
|
|
165
|
+
a glance which override was applied.
|
|
166
|
+
|
|
167
|
+
## Strict by design — what `license-gate` will NOT do
|
|
168
|
+
|
|
169
|
+
- **No license file reading.** `LICENSE`, `LICENCE`, `COPYING`, `README` are never opened.
|
|
170
|
+
- **No license inference or normalisation.** `Apache 2.0` (with space) is **not** the
|
|
171
|
+
same as `Apache-2.0`. `UNLICENSED`, `SEE LICENSE IN LICENSE.md` are literal strings.
|
|
172
|
+
- **No `spdx-correct`. No `spdx-satisfies`.** SPDX support is exclusively about parsing
|
|
173
|
+
the boolean shape of npm's `license` field — each leaf is a literal allowlist lookup.
|
|
174
|
+
- **No deprecated `license` shape unwrapping.** A package whose `package.json` has
|
|
175
|
+
`license: { type: "MIT" }` or `licenses: [...]` is treated as `could not determine`.
|
|
176
|
+
- **No clarification system, no checksum-based evidence, no `licenseStart`/`licenseEnd`.**
|
|
177
|
+
- **No project-root walk-up.** The project root is `--cwd` if provided, otherwise
|
|
178
|
+
`process.cwd()`. `license-gate` never walks upward to rescue a wrong working directory.
|
|
179
|
+
- **No `--root`, `--project`, `--allowed`, `--allowed-packages`** flags. `--cwd <path>` is
|
|
180
|
+
the only way to point the gate at a different project root, and it is an explicit
|
|
181
|
+
project root, not a search starting point.
|
|
182
|
+
- **No silent excludes.** Every package the gate considered is accounted for. The JSON
|
|
183
|
+
report (`check --json <path>`) contains a `Decision` entry for **every** evaluated
|
|
184
|
+
package — allowed and violating alike. The human report on stdout summarises the
|
|
185
|
+
allowed-by-license bucket as a count and lists every package override
|
|
186
|
+
(`matchedPackageRule`) and every violation in full; nothing is dropped.
|
|
187
|
+
- **No denylist file** — strict allowlist only.
|
|
188
|
+
- **No pnpm, yarn, Gradle, or Maven support in v1.** `license-gate` currently targets
|
|
189
|
+
npm projects and npm workspaces.
|
|
190
|
+
- **No bundle-level analysis.** Vite/Rollup/etc. are out of scope.
|
|
191
|
+
- **No markdown/tree visualisers, no SaaS upload modes, no enterprise tiers.**
|
|
192
|
+
|
|
193
|
+
## Violation model
|
|
194
|
+
|
|
195
|
+
There are exactly **two** top-level violation reasons:
|
|
196
|
+
|
|
197
|
+
| `reason` | When |
|
|
198
|
+
| -------------------------- | ------------------------------------------------------------------------------------- |
|
|
199
|
+
| `license-not-in-allowlist` | Package has a usable `license` string that does not satisfy the allowlist |
|
|
200
|
+
| `package-not-in-allowlist` | Package's license is `could not determine` and no `allowed-packages.txt` rule matches |
|
|
201
|
+
|
|
202
|
+
`license-not-in-allowlist` carries an optional `detailCode`:
|
|
203
|
+
|
|
204
|
+
| `detailCode` | When |
|
|
205
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------- |
|
|
206
|
+
| `literal-not-allowed-and-spdx-unparseable` | Literal license is not allowed, and SPDX parsing failed |
|
|
207
|
+
| `spdx-expression-not-satisfied` | SPDX parsed successfully but the expression was not satisfied; carries `offendingLeaves` |
|
|
208
|
+
|
|
209
|
+
## SPDX semantics in plain words
|
|
210
|
+
|
|
211
|
+
1. The full literal license string is checked against `allowed-hard.txt` first.
|
|
212
|
+
2. If it is not literally listed, the string is parsed as an SPDX expression
|
|
213
|
+
(OR / AND / parens; WITH-exception leaves).
|
|
214
|
+
3. Each leaf is compared literally against `allowed-hard.txt`. A WITH-exception leaf is
|
|
215
|
+
reduced to one composite literal `"<license-id> WITH <exception-id>"` and that whole
|
|
216
|
+
string must be in the allowlist; the bare licence id is not considered separately.
|
|
217
|
+
4. AND requires every leaf to be allowed; OR requires any one leaf.
|
|
218
|
+
|
|
219
|
+
## FAQ
|
|
220
|
+
|
|
221
|
+
**Why does `Apache 2.0` (with a space) fail when `Apache-2.0` is allowed?**
|
|
222
|
+
Because they are different strings. `license-gate` does not normalise — that's the
|
|
223
|
+
point. Add `Apache 2.0` to your allowlist if you intentionally accept that exact form.
|
|
224
|
+
|
|
225
|
+
**Why is `UNLICENSED` not automatically rejected?**
|
|
226
|
+
It is a literal string, not a parsed SPDX value. If you want to allow `UNLICENSED` for
|
|
227
|
+
some private dependency, add it to `allowed-packages.txt` (preferred) or, if your policy
|
|
228
|
+
genuinely accepts it everywhere, add `UNLICENSED` to `allowed-hard.txt`.
|
|
229
|
+
|
|
230
|
+
**How do I allow my own internal packages?**
|
|
231
|
+
Add your scope to `allowed-packages.txt`: `@my-co/*`. The override will be visible in
|
|
232
|
+
every report.
|
|
233
|
+
|
|
234
|
+
**Why does it refuse to walk up to my workspace root?**
|
|
235
|
+
Because doing so would silently change which project is being gated, and we want the
|
|
236
|
+
project under check to be exactly what the user typed. `cd` to the workspace root and
|
|
237
|
+
run `license-gate check --workspace apps/web`, or pass `--cwd <workspace-root>
|
|
238
|
+
--workspace apps/web` from anywhere.
|
|
239
|
+
|
|
240
|
+
**Why is `check --out` rejected?**
|
|
241
|
+
The check verdict belongs in stdout / CI logs. Use `--json <path>` for a structured
|
|
242
|
+
report; use shell redirection if you need a copy of the human output.
|
|
243
|
+
|
|
244
|
+
## Programmatic API
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { runCheck, runCollect } from '@mirasen/license-gate';
|
|
248
|
+
|
|
249
|
+
const result = await runCheck({ workspace: 'apps/web', jsonPath: 'report.json' });
|
|
250
|
+
if (result.exitCode === 1) {
|
|
251
|
+
console.error(`license-gate found ${result.violations.length} violation(s).`);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The library never calls `process.exit`. Callers control exit. See `src/index.ts` for the
|
|
256
|
+
full set of exported types (`Decision`, `ViolationReason`, `InstalledPackageRecord`,
|
|
257
|
+
`LicenseGateConfigError`, etc.).
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
[MIT](./LICENSE) © Mirasen
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `license-gate` CLI entry.
|
|
4
|
+
*
|
|
5
|
+
* Thin: parse argv with node:util.parseArgs, dispatch to command modules,
|
|
6
|
+
* map LicenseGateConfigError to exit code 2 with a stderr message. All
|
|
7
|
+
* business logic lives in src/commands/.
|
|
8
|
+
*/
|
|
9
|
+
type ExitCode = 0 | 1 | 2;
|
|
10
|
+
export declare function main(argv: string[]): Promise<ExitCode>;
|
|
11
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `license-gate` CLI entry.
|
|
4
|
+
*
|
|
5
|
+
* Thin: parse argv with node:util.parseArgs, dispatch to command modules,
|
|
6
|
+
* map LicenseGateConfigError to exit code 2 with a stderr message. All
|
|
7
|
+
* business logic lives in src/commands/.
|
|
8
|
+
*/
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
10
|
+
import { parseArgs } from 'node:util';
|
|
11
|
+
import { runCheck } from './commands/check.js';
|
|
12
|
+
import { runCollect } from './commands/collect.js';
|
|
13
|
+
import { LicenseGateConfigError } from './types.js';
|
|
14
|
+
function usageString() {
|
|
15
|
+
return [
|
|
16
|
+
'Usage:',
|
|
17
|
+
' license-gate check [--cwd <path>] [--workspace <name|path>] [--json <path>]',
|
|
18
|
+
' license-gate collect [--cwd <path>] [--workspace <name|path>] [--out <path>] [--json <path>]',
|
|
19
|
+
'',
|
|
20
|
+
'Reads policy from licenses/allowed-hard.txt (required for check) and',
|
|
21
|
+
'licenses/allowed-packages.txt (optional), resolved relative to the project root.',
|
|
22
|
+
'The project root is --cwd if provided, otherwise process.cwd(). license-gate',
|
|
23
|
+
'never walks upward — package.json must exist directly at the chosen root.',
|
|
24
|
+
'',
|
|
25
|
+
'Exit codes: 0 clean | 1 violations | 2 usage/config error.'
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
function fail(message) {
|
|
29
|
+
process.stderr.write(`license-gate: ${message}\n`);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
function failConfig(err) {
|
|
33
|
+
process.stderr.write(`${err.message}\n`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
async function dispatchCheck(rest) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parseArgs({
|
|
40
|
+
args: rest,
|
|
41
|
+
strict: true,
|
|
42
|
+
allowPositionals: false,
|
|
43
|
+
options: {
|
|
44
|
+
cwd: { type: 'string' },
|
|
45
|
+
workspace: { type: 'string' },
|
|
46
|
+
json: { type: 'string' }
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return fail(`${err.message}\n${usageString()}`);
|
|
52
|
+
}
|
|
53
|
+
const cwd = parsed.values.cwd ?? undefined;
|
|
54
|
+
const workspace = parsed.values.workspace ?? null;
|
|
55
|
+
const jsonPath = parsed.values.json ?? null;
|
|
56
|
+
try {
|
|
57
|
+
const result = await runCheck({ cwd, workspace, jsonPath });
|
|
58
|
+
process.stdout.write(result.humanReport);
|
|
59
|
+
return result.exitCode;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof LicenseGateConfigError)
|
|
63
|
+
return failConfig(err);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function dispatchCollect(rest) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = parseArgs({
|
|
71
|
+
args: rest,
|
|
72
|
+
strict: true,
|
|
73
|
+
allowPositionals: false,
|
|
74
|
+
options: {
|
|
75
|
+
cwd: { type: 'string' },
|
|
76
|
+
workspace: { type: 'string' },
|
|
77
|
+
out: { type: 'string' },
|
|
78
|
+
json: { type: 'string' }
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return fail(`${err.message}\n${usageString()}`);
|
|
84
|
+
}
|
|
85
|
+
const cwd = parsed.values.cwd ?? undefined;
|
|
86
|
+
const workspace = parsed.values.workspace ?? null;
|
|
87
|
+
const outPath = parsed.values.out ?? null;
|
|
88
|
+
const jsonPath = parsed.values.json ?? null;
|
|
89
|
+
try {
|
|
90
|
+
const result = await runCollect({ cwd, workspace, outPath, jsonPath });
|
|
91
|
+
if (result.stdoutSummary) {
|
|
92
|
+
process.stdout.write(result.stdoutSummary);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
process.stdout.write(result.humanReport);
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof LicenseGateConfigError)
|
|
101
|
+
return failConfig(err);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function main(argv) {
|
|
106
|
+
const [subcommand, ...rest] = argv;
|
|
107
|
+
if (!subcommand) {
|
|
108
|
+
process.stderr.write(`${usageString()}\n`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
switch (subcommand) {
|
|
112
|
+
case '-h':
|
|
113
|
+
case '--help':
|
|
114
|
+
process.stdout.write(`${usageString()}\n`);
|
|
115
|
+
return 0;
|
|
116
|
+
case 'check': {
|
|
117
|
+
// Reject `check --out` explicitly with invalid-usage (do this before
|
|
118
|
+
// parseArgs, because parseArgs would simply mark `--out` as unknown).
|
|
119
|
+
if (rest.some((a) => a === '--out' || a.startsWith('--out='))) {
|
|
120
|
+
return failConfig(new LicenseGateConfigError({
|
|
121
|
+
kind: 'invalid-usage',
|
|
122
|
+
message: '`check` does not support --out. Use --json <path> for machine-readable output.'
|
|
123
|
+
}, 'license-gate: `check` does not support --out. Use --json <path> for machine-readable output.'));
|
|
124
|
+
}
|
|
125
|
+
return dispatchCheck(rest);
|
|
126
|
+
}
|
|
127
|
+
case 'collect':
|
|
128
|
+
return dispatchCollect(rest);
|
|
129
|
+
default:
|
|
130
|
+
return fail(`unknown command "${subcommand}".\n${usageString()}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Entrypoint guard: only run main() when this file is the program entry,
|
|
134
|
+
// not when it's imported as a module by a test. We resolve `process.argv[1]`
|
|
135
|
+
// through `realpathSync` so that the comparison works when the binary was
|
|
136
|
+
// installed as a symlink in `node_modules/.bin`.
|
|
137
|
+
const isDirectRun = (() => {
|
|
138
|
+
try {
|
|
139
|
+
const argv1 = process.argv[1];
|
|
140
|
+
if (!argv1)
|
|
141
|
+
return false;
|
|
142
|
+
const resolved = realpathSync(argv1);
|
|
143
|
+
const url = new URL(`file://${resolved}`);
|
|
144
|
+
return import.meta.url === url.href;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
if (isDirectRun) {
|
|
151
|
+
main(process.argv.slice(2)).then((code) => {
|
|
152
|
+
process.exit(code);
|
|
153
|
+
}, (err) => {
|
|
154
|
+
process.stderr.write(`license-gate: unexpected error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared command-layer helpers: writing output files, resolving allowlist
|
|
3
|
+
* paths, and re-exporting the project-owned scope resolver.
|
|
4
|
+
*
|
|
5
|
+
* Arborist types live in the graph layer; this module never sees them.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveScope, type ResolvedScope } from '../graph/scope.js';
|
|
8
|
+
export { resolveScope };
|
|
9
|
+
export type { ResolvedScope };
|
|
10
|
+
/** Write a string to a path, raising LicenseGateConfigError on failure. */
|
|
11
|
+
export declare function writeOutputFile(path: string, contents: string, cwd: string): Promise<void>;
|
|
12
|
+
/** Resolve fixed allowlist paths relative to cwd. */
|
|
13
|
+
export declare function resolveAllowlistPaths(cwd: string): {
|
|
14
|
+
allowedHard: string;
|
|
15
|
+
allowedPackages: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function fileExists(path: string): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared command-layer helpers: writing output files, resolving allowlist
|
|
3
|
+
* paths, and re-exporting the project-owned scope resolver.
|
|
4
|
+
*
|
|
5
|
+
* Arborist types live in the graph layer; this module never sees them.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { resolveScope } from '../graph/scope.js';
|
|
11
|
+
import { LicenseGateConfigError } from '../types.js';
|
|
12
|
+
export { resolveScope };
|
|
13
|
+
/** Write a string to a path, raising LicenseGateConfigError on failure. */
|
|
14
|
+
export async function writeOutputFile(path, contents, cwd) {
|
|
15
|
+
const absolute = resolve(cwd, path);
|
|
16
|
+
try {
|
|
17
|
+
await writeFile(absolute, contents, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
21
|
+
throw new LicenseGateConfigError({ kind: 'output-path-unwritable', path: absolute, cause }, `license-gate: could not write to ${absolute}: ${cause}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Resolve fixed allowlist paths relative to cwd. */
|
|
25
|
+
export function resolveAllowlistPaths(cwd) {
|
|
26
|
+
return {
|
|
27
|
+
allowedHard: resolve(cwd, 'licenses', 'allowed-hard.txt'),
|
|
28
|
+
allowedPackages: resolve(cwd, 'licenses', 'allowed-packages.txt')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function fileExists(path) {
|
|
32
|
+
return existsSync(path);
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check` command — orchestrates discovery → policy → reporting → exit code.
|
|
3
|
+
*/
|
|
4
|
+
import { type CheckResult } from '../types.js';
|
|
5
|
+
export type CheckOptions = {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
workspace?: string | null;
|
|
8
|
+
/** When provided, JSON report is written to this path (relative to cwd). */
|
|
9
|
+
jsonPath?: string | null;
|
|
10
|
+
};
|
|
11
|
+
export type CheckCliResult = CheckResult & {
|
|
12
|
+
humanReport: string;
|
|
13
|
+
exitCode: 0 | 1;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Programmatic + CLI-shared check. Returns the decisions and the rendered
|
|
17
|
+
* human report; the caller (CLI or library consumer) decides what to do
|
|
18
|
+
* with stdout and the exit code.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runCheck(opts?: CheckOptions): Promise<CheckCliResult>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check` command — orchestrates discovery → policy → reporting → exit code.
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { evaluateAll } from '../policy/evaluate.js';
|
|
6
|
+
import { parseAllowedHard } from '../policy/allowed-hard.js';
|
|
7
|
+
import { parseAllowedPackages } from '../policy/allowed-packages.js';
|
|
8
|
+
import { renderCheckHuman } from '../report/human.js';
|
|
9
|
+
import { renderCheckJson } from '../report/json.js';
|
|
10
|
+
import { LicenseGateConfigError } from '../types.js';
|
|
11
|
+
import { fileExists, resolveAllowlistPaths, resolveScope, writeOutputFile } from './_shared.js';
|
|
12
|
+
/**
|
|
13
|
+
* Programmatic + CLI-shared check. Returns the decisions and the rendered
|
|
14
|
+
* human report; the caller (CLI or library consumer) decides what to do
|
|
15
|
+
* with stdout and the exit code.
|
|
16
|
+
*/
|
|
17
|
+
export async function runCheck(opts = {}) {
|
|
18
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
19
|
+
const workspace = opts.workspace ?? null;
|
|
20
|
+
const scope = await resolveScope({ cwd, workspace });
|
|
21
|
+
const paths = resolveAllowlistPaths(cwd);
|
|
22
|
+
if (!fileExists(paths.allowedHard)) {
|
|
23
|
+
throw new LicenseGateConfigError({ kind: 'missing-allowed-hard-file', path: paths.allowedHard }, `license-gate: required allowlist file not found: ${paths.allowedHard}`);
|
|
24
|
+
}
|
|
25
|
+
const allowedHardContents = await readFile(paths.allowedHard, 'utf8');
|
|
26
|
+
const allowedHard = parseAllowedHard(allowedHardContents);
|
|
27
|
+
const allowedPackages = fileExists(paths.allowedPackages)
|
|
28
|
+
? parseAllowedPackages(await readFile(paths.allowedPackages, 'utf8'), paths.allowedPackages)
|
|
29
|
+
: [];
|
|
30
|
+
// Collect-all-then-exit: never throw mid-evaluation.
|
|
31
|
+
const decisions = evaluateAll(scope.evaluationRecords, allowedHard, allowedPackages);
|
|
32
|
+
const violations = decisions.filter((d) => d.outcome === 'violation');
|
|
33
|
+
const humanReport = renderCheckHuman({
|
|
34
|
+
decisions,
|
|
35
|
+
skippedProjectRoot: scope.skippedProjectRoot
|
|
36
|
+
});
|
|
37
|
+
if (opts.jsonPath) {
|
|
38
|
+
const jsonReport = renderCheckJson({
|
|
39
|
+
decisions,
|
|
40
|
+
skippedProjectRoot: scope.skippedProjectRoot
|
|
41
|
+
});
|
|
42
|
+
await writeOutputFile(opts.jsonPath, jsonReport, cwd);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
decisions,
|
|
46
|
+
violations,
|
|
47
|
+
skippedProjectRoot: scope.skippedProjectRoot,
|
|
48
|
+
humanReport,
|
|
49
|
+
exitCode: violations.length > 0 ? 1 : 0
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `collect` command — orchestrates discovery → flat record list → reporting.
|
|
3
|
+
*
|
|
4
|
+
* Performs NO policy I/O: never reads `allowed-hard.txt` or
|
|
5
|
+
* `allowed-packages.txt`. Output is human-readable by default; `--out <path>`
|
|
6
|
+
* writes to a file (and stdout shrinks to a one-line summary); `--json
|
|
7
|
+
* <path>` writes machine-readable JSON.
|
|
8
|
+
*/
|
|
9
|
+
import type { CollectResult } from '../types.js';
|
|
10
|
+
export type CollectOptions = {
|
|
11
|
+
cwd?: string;
|
|
12
|
+
workspace?: string | null;
|
|
13
|
+
/** When provided, human report is written to this path. */
|
|
14
|
+
outPath?: string | null;
|
|
15
|
+
/** When provided, JSON report is written to this path. */
|
|
16
|
+
jsonPath?: string | null;
|
|
17
|
+
};
|
|
18
|
+
export type CollectCliResult = CollectResult & {
|
|
19
|
+
/** Human report. Always rendered; the CLI decides whether to print. */
|
|
20
|
+
humanReport: string;
|
|
21
|
+
/** When --out was provided, this is the one-line stdout summary. */
|
|
22
|
+
stdoutSummary: string | null;
|
|
23
|
+
};
|
|
24
|
+
export declare function runCollect(opts?: CollectOptions): Promise<CollectCliResult>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `collect` command — orchestrates discovery → flat record list → reporting.
|
|
3
|
+
*
|
|
4
|
+
* Performs NO policy I/O: never reads `allowed-hard.txt` or
|
|
5
|
+
* `allowed-packages.txt`. Output is human-readable by default; `--out <path>`
|
|
6
|
+
* writes to a file (and stdout shrinks to a one-line summary); `--json
|
|
7
|
+
* <path>` writes machine-readable JSON.
|
|
8
|
+
*/
|
|
9
|
+
import { renderCollectHuman } from '../report/human.js';
|
|
10
|
+
import { renderCollectJson } from '../report/json.js';
|
|
11
|
+
import { resolveScope, writeOutputFile } from './_shared.js';
|
|
12
|
+
export async function runCollect(opts = {}) {
|
|
13
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
14
|
+
const workspace = opts.workspace ?? null;
|
|
15
|
+
const scope = await resolveScope({ cwd, workspace });
|
|
16
|
+
const records = scope.allCollectedRecords;
|
|
17
|
+
const humanReport = renderCollectHuman(records);
|
|
18
|
+
let stdoutSummary = null;
|
|
19
|
+
if (opts.outPath) {
|
|
20
|
+
await writeOutputFile(opts.outPath, humanReport, cwd);
|
|
21
|
+
stdoutSummary = `wrote ${records.length} records to ${opts.outPath}\n`;
|
|
22
|
+
}
|
|
23
|
+
if (opts.jsonPath) {
|
|
24
|
+
await writeOutputFile(opts.jsonPath, renderCollectJson(records), cwd);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
records,
|
|
28
|
+
humanReport,
|
|
29
|
+
stdoutSummary
|
|
30
|
+
};
|
|
31
|
+
}
|