@vola-trebla/croak 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -2
  3. package/dist/cli.js +347 -0
  4. package/dist/crypto/der.js +123 -0
  5. package/dist/crypto/fields.js +49 -0
  6. package/dist/crypto/registry-signature.js +76 -0
  7. package/dist/crypto/rekor.js +57 -0
  8. package/dist/crypto/sigstore.js +112 -0
  9. package/dist/detectors/git-config.js +133 -0
  10. package/dist/detectors/hidden-code.js +51 -0
  11. package/dist/detectors/ide-autorun.js +127 -0
  12. package/dist/detectors/index.js +19 -0
  13. package/dist/detectors/lifecycle-script.js +298 -0
  14. package/dist/detectors/node-modules-present.js +25 -0
  15. package/dist/detectors/obfuscation.js +98 -0
  16. package/dist/detectors/unscanned.js +33 -0
  17. package/dist/hook/hook.js +348 -0
  18. package/dist/hook/install-commands.js +892 -0
  19. package/dist/install/content-cache.js +86 -0
  20. package/dist/install/content.js +32 -0
  21. package/dist/install/detectors.js +73 -0
  22. package/dist/install/installed.js +83 -0
  23. package/dist/install/lockfile.js +65 -0
  24. package/dist/install/metadata.js +129 -0
  25. package/dist/install/plan.js +21 -0
  26. package/dist/install/pnpm-builds.js +98 -0
  27. package/dist/install/pnpm-lockfile.js +88 -0
  28. package/dist/install/popular.js +172 -0
  29. package/dist/install/project.js +203 -0
  30. package/dist/install/provenance.js +56 -0
  31. package/dist/install/registry.js +241 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +74 -0
  34. package/dist/install/scan.js +271 -0
  35. package/dist/install/signature.js +55 -0
  36. package/dist/install/tar.js +171 -0
  37. package/dist/install/workspace.js +30 -0
  38. package/dist/install/yaml.js +256 -0
  39. package/dist/llm/client.js +14 -0
  40. package/dist/llm/cost.js +24 -0
  41. package/dist/llm/diff.js +100 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +86 -0
  44. package/dist/llm/select.js +15 -0
  45. package/dist/llm/triage.js +101 -0
  46. package/dist/repo-report.js +84 -0
  47. package/dist/repo-snapshot.js +140 -0
  48. package/dist/scan.js +43 -0
  49. package/dist/signals/code.js +107 -0
  50. package/dist/signals/git-config.js +51 -0
  51. package/dist/signals/hidden.js +60 -0
  52. package/dist/signals/jsonc.js +90 -0
  53. package/dist/signals/obfuscation.js +87 -0
  54. package/dist/signals/shell.js +677 -0
  55. package/dist/signals/text.js +1 -0
  56. package/dist/ui.js +11 -0
  57. package/dist/verdict.js +19 -0
  58. package/package.json +71 -4
  59. package/scripts/croak-hook.sh +59 -0
  60. package/scripts/croak-install.sh +262 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vola-trebla
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 CHANGED
@@ -1,3 +1,284 @@
1
- # Temporary Holding Version
1
+ # 🐸 croak
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Croak before you install.**
4
+
5
+ `croak` checks what a package install — or an unfamiliar repo — is about to do
6
+ **before any of its code runs on your machine**. It's built for AI coding agents
7
+ first: when an agent reaches for `npm install`, croak stops it, explains why,
8
+ and lets the agent fix its own mistake.
9
+
10
+ > 🚧 **Status: early stage (0.x).** `croak repo`, the npm and pnpm install
11
+ > checks, signature and provenance verification, the optional LLM layer and
12
+ > the Claude Code hook work end to end, and each milestone since M1 has had a
13
+ > security review. It is young software: use it, but don't make it your only
14
+ > line of defense, and expect the CLI to change before 1.0. The
15
+ > [roadmap](ROADMAP.md) shows what's done.
16
+
17
+ ---
18
+
19
+ ## 😱 The problem
20
+
21
+ `npm install` doesn't just download code. It **runs** it: `postinstall`
22
+ scripts, native builds, and whatever else a package decides to do — with your
23
+ SSH keys, cloud tokens, `.env` files, and crypto wallets one `fs.readFile` away.
24
+
25
+ And in 2026 this is happening constantly:
26
+ - 📦 **Popular packages get hijacked.** A maintainer account is taken over, a
27
+ new version ships with a hidden dependency, its `postinstall` drops a remote
28
+ access trojan. It's live for hours before anyone notices.
29
+ - 🤖 **AI agents install things without looking.** Models invent package names
30
+ that don't exist; attackers register those names and wait
31
+ (_slopsquatting_). The agent runs the install. Nobody reviews it.
32
+ - 💼 **Fake job interviews.** "Here's our test assignment, just clone and run
33
+ `npm install`." The repo opens a VS Code task on folder open, or hides a
34
+ payload after 300 spaces in `tailwind.config.js`.
35
+ - 🙈 **`--ignore-scripts` isn't enough.** Some attacks run through `binding.gyp`
36
+ and native builds, with no lifecycle scripts at all.
37
+
38
+ ## 🛂 What croak does
39
+
40
+ Think of `npm install` as a courier whose parcels **unpack themselves**.
41
+ croak is the customs officer at your door. It x-rays the contents as plain
42
+ data and never lets anything inside run (how: [security model](docs/security-model.md)):
43
+
44
+ | | Check | Catches |
45
+ | --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | 🔍 | **Does the sender exist?** | hallucinated and typosquatted packages |
47
+ | ⚖️ | **What changed since the last delivery?** | a new dependency or install script that appeared in a patch release |
48
+ | 🩻 | **Will anything run on unpack?** | `postinstall`, `binding.gyp`, prebuilt binaries, IDE autorun tasks |
49
+ | 🕵️ | **Is something hidden?** | obfuscated code, payloads in build configs |
50
+ | 🔏 | **Is the seal intact?** | a registry signature or [provenance](https://docs.npmjs.com/generating-provenance-statements) that doesn't verify, a build from another repo |
51
+ | 🤖 | **Ask an expert (optional)** | an LLM reviews the _diff_ between versions — and can only raise the alarm, never lower it |
52
+
53
+ Then it gives a verdict — 🟢 **allow** (nothing suspicious found, not a
54
+ guarantee), 🟡 **warn** (needs your attention, never "safe"), or 🔴 **block** — with
55
+ reasons a human _and_ an agent can act on:
56
+
57
+ ```
58
+ $ croak-install reactt-domm
59
+ croak-install: a requested package does not exist on npm. Check the name:
60
+ AI agents often invent plausible package names, and attackers register them.
61
+ reactt-domm: did you mean `react-dom`?
62
+
63
+ $ croak-install esbuild
64
+ croak plan - WARN
65
+ WARN esbuild@0.28.2/package.json:10 repo/lifecycle-script
66
+ postinstall runs `node install.js` on install: network (install.js:93), process (install.js:95), decode (install.js:166)
67
+ INFO package-lock.json install/other-platform
68
+ 25 package(s) built for other platforms not checked: npm will not install them on darwin-arm64
69
+ ```
70
+
71
+ ## 📦 Install and use
72
+
73
+ ```bash
74
+ npm install -g @vola-trebla/croak # Node 24.2+, macOS or Linux
75
+ ```
76
+
77
+ Or once, without installing (fitting for a tool about installs: croak has
78
+ no install scripts and one dependency, Anthropic's SDK):
79
+
80
+ ```bash
81
+ npx @vola-trebla/croak repo ./that-test-assignment
82
+ ```
83
+
84
+ ### Two modes
85
+
86
+ ```bash
87
+ croak-install [pkg] # plan an npm install, check it before anything executes
88
+ croak-install --pnpm [pkg] # the same for pnpm
89
+ croak repo . # scan an unfamiliar repo before you install or open it
90
+ ```
91
+
92
+ Plus a **Claude Code hook** that puts croak in front of every install
93
+ command an agent runs. Add it to `~/.claude/settings.json` (or a project's
94
+ `.claude/settings.json`; croak never edits your config), with the path that
95
+ `command -v croak-hook` prints:
96
+
97
+ ```json
98
+ {
99
+ "hooks": {
100
+ "PreToolUse": [
101
+ {
102
+ "matcher": "Bash",
103
+ "hooks": [
104
+ {
105
+ "type": "command",
106
+ "command": "/usr/local/bin/croak-hook",
107
+ "timeout": 300,
108
+ "onFailure": "block"
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ }
115
+ ```
116
+
117
+ 🔴, an incomplete check, or a command croak cannot vouch for (yarn or bun, a
118
+ custom registry, `npm i $PKG`, an `.npmrc` that points at a private
119
+ registry) → the command is denied and the agent is told why;
120
+ 🟡 → Claude Code asks you; 🟢 → the hook stays silent and your normal
121
+ permissions apply. `onFailure: "block"` matters: without it, Claude Code lets
122
+ a command through when a hook crashes or times out. It needs Claude Code
123
+ 2.1.295 or later. The hook runs `node` from Claude Code's own PATH: if that
124
+ finds a Node older than 24.2 (common with nvm when Claude Code starts from
125
+ the desktop), every install is denied with a message saying so, so put
126
+ Node 24 first on that PATH.
127
+
128
+ **The AI layer (optional).** With `CROAK_LLM=1` and `ANTHROPIC_API_KEY` set
129
+ (for the hook: in the environment Claude Code runs it with; for
130
+ `croak plan`: `--llm`), croak asks Claude Haiku 5.5 about each package the
131
+ rules only warned about, showing it what changed since the previous
132
+ version. Its answer can only add a 🟡 or a 🔴, never remove one; without a
133
+ key, or when the model fails, the rules decide alone. Only the diffs of
134
+ public npm packages are sent, never your project's files. On croak's eval
135
+ it turned four red-team variants of one decode-and-run payload from 🟡 into
136
+ 🔴 (none of their tricks fooled it) with no false alarms, and on 61 popular
137
+ packages it added no blocks, for about $0.001 a package
138
+ ([eval/README.md](eval/README.md#llm-ablation-m5)).
139
+
140
+ **`croak-install`** has npm resolve the install with `--package-lock-only
141
+ --ignore-scripts` in an isolated temp directory (your project's lockfile is
142
+ not touched), then `croak plan` checks every version that is not installed
143
+ yet, transitive ones included (on a fresh clone, the whole lockfile): the lockfile (install scripts, sources, integrity),
144
+ the registry (release age, provenance, publisher, look-alike names, brand-new
145
+ packages and dependencies), the registry's signature and the Sigstore
146
+ provenance, verified by croak itself against keys it pins (did the same
147
+ repository and workflow build this version as the last one?), and the package contents, downloaded and verified
148
+ against the lockfile hash and read in memory, never extracted or run.
149
+
150
+ **pnpm** (`croak-install --pnpm`, and `pnpm add/install/update/dlx`, `pnpx`
151
+ in the hook) gets the same checks, plus what pnpm does differently:
152
+ pnpm-lock.yaml is read by a strict parser of croak's own; an install script
153
+ that pnpm 10+ will not run (the project does not allow its builds) is noted,
154
+ not warned about; the project's `.pnpmfile.cjs` and config dependencies,
155
+ which pnpm runs on every install, are checked; and planning never lets a
156
+ project pick the pnpm binary (`packageManager`) or load a pnpmfile.
157
+ Workspaces, npm and pnpm, are checked from their root, with every member's
158
+ own install scripts; a folder inside the workspace that it does not list as
159
+ a member is checked as a project of its own, as npm installs it.
160
+
161
+ **`croak repo`** runs seven detectors: install scripts, VS Code autorun tasks,
162
+ hidden code, obfuscation, dangerous git config, committed `node_modules`, and
163
+ files too large to scan.
164
+
165
+ Content checks are cached by tarball hash in `~/.cache/croak` (registry facts
166
+ are always fresh), so a repeated install is checked in under a second.
167
+
168
+ Exit codes: `0` allow/warn, `1` block, `2` usage error, `3` check incomplete —
169
+ agents should treat `3` as a block. `--json` gives a versioned report for tools.
170
+
171
+ ## 🧠 Design principles
172
+
173
+ - 🚫 **Never execute untrusted code.** Everything is static. Install plans are
174
+ computed with `--package-lock-only --ignore-scripts`.
175
+ - 🧮 **Deterministic first, AI second.** Rules are fast, free, and work offline.
176
+ The LLM layer is optional (bring your own Anthropic key) and can only
177
+ _escalate_ — so a package that says "AI, this code is safe" gets nowhere.
178
+ - 🔒 **Fail-closed for agents, warn for humans.** If a check can't complete, an
179
+ agent waits; a human decides.
180
+ - 📊 **Eval-driven.** No detector ships without test cases. Every change is
181
+ measured.
182
+ - 🪶 **One runtime dependency, and only for the optional AI layer.** croak is
183
+ itself an npm package, so it must not become the next compromised
184
+ dependency: the deterministic checks use Node built-ins only, and the one
185
+ exception is Anthropic's official SDK, pinned to an exact version and
186
+ checked by croak itself (D36).
187
+
188
+ ## 🙅 What croak won't catch
189
+
190
+ Being honest about limits matters more than marketing:
191
+ - Code that's clean at install and turns malicious later, at runtime
192
+ - Install scripts that pull the payload in through `require` from the file they
193
+ run: croak reads the script and the files it runs directly, not deeper (yet)
194
+ - Payloads in library code that run only when your app calls it, not at install
195
+ (chalk and debug, 2025): only the content checks can see those
196
+ - Long-game maintainer takeovers (think xz-utils), or a decoy release published
197
+ more than a week before the real attack
198
+ - A compromised registry or signing infrastructure
199
+ - An install the agent hides from the hook: inside a package.json script it
200
+ runs by hand (`npm run setup`, `bash setup.sh`), behind `eval "$VAR"`, or
201
+ through another package manager once you allow it. (A plain `npm install`
202
+ or `npm ci` does check the project's own install scripts.)
203
+ - npm settings croak does not read: the global npmrc and npm's built-in
204
+ config (both yours, not the repo's)
205
+ - Malware that's already on your machine
206
+ - Payloads that only wake up in one specific environment
207
+
208
+ croak is a gate, not an antivirus.
209
+
210
+ ## 📊 The eval
211
+
212
+ croak is measured against an exam: synthetic malicious repos and installs
213
+ (with inert payloads, served from a fake registry for installs) and ordinary
214
+ ones that must _not_ trigger alarms. Each install case maps to a real attack
215
+ (axios 2026, Shai-Hulud 2025, ua-parser-js 2021…), checked against OSV.
216
+
217
+ ```bash
218
+ pnpm eval
219
+ ```
220
+
221
+ ```
222
+ 🐸 croak eval — 67 cases (35 malicious, 32 benign)
223
+ …
224
+ block recall 26/35 74% ███████████████░░░░░
225
+ flag recall 35/35 100% ████████████████████
226
+ false alarms 0/32 0% ░░░░░░░░░░░░░░░░░░░░
227
+ rule recall 52/52 100% ████████████████████
228
+ ```
229
+
230
+ Synthetic cases are written by us, so two more runs check the noise on code we
231
+ did not write: 13 pinned real-world repositories (`pnpm corpus`) and 61 pinned
232
+ popular npm packages (`pnpm corpus:packages`: 0 blocks; the warnings are
233
+ fresh releases and real install scripts). A case that ever passed is not
234
+ allowed to fail again. Format, metrics and the real attacks behind each case:
235
+ [eval/README.md](eval/README.md).
236
+
237
+ ## 🗺️ Roadmap
238
+
239
+ | | Milestone |
240
+ | --- | --------------------------------------------------------------------------- |
241
+ | ✅ | **M0** — foundation: TypeScript, tests, CI, supply-chain-hardened workflows |
242
+ | ✅ | **M1** — eval first: case format, runner, metrics, regression guard |
243
+ | ✅ | **M2** — `croak repo`: autorun configs, hidden code, obfuscation |
244
+ | ✅ | **M3** — npm install plans, registry and package-content signals |
245
+ | ✅ | **M4** — Claude Code hook, agent-friendly verdicts |
246
+ | ✅ | **M5** — LLM triage of version diffs (Haiku by default) |
247
+ | ✅ | **M6** — pnpm and workspaces |
248
+ | ✅ | **M7** — registry signatures + provenance verification |
249
+ | ⏳ | **M8** — public release |
250
+ | 💭 | Later: Python, shared signed verdicts, tarball vs source (D44) |
251
+
252
+ Details in [ROADMAP.md](ROADMAP.md); the reasoning behind every choice is in
253
+ [docs/decisions.md](docs/decisions.md).
254
+
255
+ ## 🛠️ Development
256
+
257
+ ```bash
258
+ nvm use # Node 24.2+
259
+ pnpm install
260
+ git config core.hooksPath .githooks # pre-commit + pre-push checks
261
+ pnpm check # lint + typecheck + invariants + tests
262
+ pnpm eval # run the eval
263
+ pnpm corpus:fetch && pnpm corpus # false-alarm check on real repos (network)
264
+ pnpm corpus:packages # false-alarm check on real npm packages (network)
265
+ pnpm croak repo <path> # run croak from source
266
+ ```
267
+
268
+ Working with an AI agent on this repo? Start with [CLAUDE.md](CLAUDE.md).
269
+
270
+ ## 🎓 Origin story
271
+
272
+ In 2013 I wrote a university thesis on elliptic-curve cryptography: point
273
+ addition, Diffie–Hellman, a little C# client-server app. I didn't really get
274
+ why any of it mattered.
275
+
276
+ In 2026, those same curves sign the provenance of npm packages — and someone
277
+ sent me a "test assignment" that I had an AI agent inspect before installing.
278
+ It told me not to touch it.
279
+
280
+ croak is what that thesis turns into thirteen years later.
281
+
282
+ ## 📄 License
283
+
284
+ [MIT](LICENSE). Found a way around croak? See [SECURITY.md](SECURITY.md).
package/dist/cli.js ADDED
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, posix } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { runHook } from "./hook/hook.js";
7
+ import { codeFingerprint, fileContentCache, } from "./install/content-cache.js";
8
+ import { installedLockfile, installedPnpmLockfile, } from "./install/installed.js";
9
+ import { suggestNames } from "./install/metadata.js";
10
+ import { pnpmBuildPolicy } from "./install/pnpm-builds.js";
11
+ import { isPnpmLockfile, parsePnpmLockfile } from "./install/pnpm-lockfile.js";
12
+ import { pnpmfileFindings, projectScriptFindings, workspaceMembers, } from "./install/project.js";
13
+ import { planToJson, renderPlan } from "./install/report.js";
14
+ import { SanitizeError, sanitizePnpmWorkspace, sanitizeRootManifest, } from "./install/sanitize.js";
15
+ import { planInstall, planInstallOnline, } from "./install/scan.js";
16
+ import { workspaceRoot } from "./install/workspace.js";
17
+ import { anthropicTriage, DEFAULT_MODEL } from "./llm/client.js";
18
+ import { renderScan, scanToJson } from "./repo-report.js";
19
+ import { ScanTargetError, scanRepoAt } from "./scan.js";
20
+ import { aggregate } from "./verdict.js";
21
+ export const VERSION = "0.1.1";
22
+ export const EXIT = { ok: 0, block: 1, usage: 2, incomplete: 3 };
23
+ export const EXIT_USAGE = EXIT.usage;
24
+ const DEFAULT_DEPS = {
25
+ scan: (root) => scanRepoAt(root),
26
+ emoji: false,
27
+ };
28
+ const HELP = `croak 🐸 — croak before you install
29
+
30
+ Usage:
31
+ croak plan --after <lock> [--before <lock|none> | --installed <project>] [--project <dir>] [--member <dir>] [--llm]
32
+ [--pnpm-builds <dir> [--pnpm-version <v>]]
33
+ [--offline | --metadata-only] [--no-cache]
34
+ judge an install from the lockfile npm planned
35
+ croak repo [path] scan a repo before you install or open it (default: .)
36
+ croak hook plan|decide the Claude Code hook's steps (used by croak-hook)
37
+ croak --version
38
+ croak --help
39
+
40
+ Options for repo:
41
+ --json machine-readable report
42
+ --plain no emoji, even in a terminal
43
+
44
+ Exit codes: 0 allow/warn · 1 block · 2 usage error · 3 scan incomplete
45
+ `;
46
+ function isParseArgsError(error) {
47
+ return (error instanceof TypeError &&
48
+ "code" in error &&
49
+ typeof error.code === "string" &&
50
+ error.code.startsWith("ERR_PARSE_ARGS_"));
51
+ }
52
+ function parse(argv) {
53
+ return parseArgs({
54
+ args: [...argv],
55
+ options: {
56
+ version: { type: "boolean", short: "v" },
57
+ help: { type: "boolean", short: "h" },
58
+ json: { type: "boolean" },
59
+ plain: { type: "boolean" },
60
+ before: { type: "string" },
61
+ after: { type: "string" },
62
+ offline: { type: "boolean" },
63
+ "metadata-only": { type: "boolean" },
64
+ "no-cache": { type: "boolean" },
65
+ installed: { type: "string" },
66
+ project: { type: "string" },
67
+ member: { type: "string" },
68
+ llm: { type: "boolean" },
69
+ "pnpm-builds": { type: "string" },
70
+ "pnpm-version": { type: "string" },
71
+ },
72
+ allowPositionals: true,
73
+ strict: true,
74
+ });
75
+ }
76
+ const usageError = (message) => ({
77
+ code: EXIT.usage,
78
+ stdout: "",
79
+ stderr: `croak: ${message}\n\n${HELP}`,
80
+ });
81
+ function exitCode(outcome) {
82
+ if (outcome.status === "incomplete")
83
+ return EXIT.incomplete;
84
+ return outcome.verdict === "block" ? EXIT.block : EXIT.ok;
85
+ }
86
+ async function runPlan(values, rest, deps) {
87
+ if (rest.length > 0)
88
+ return usageError(`plan takes no positional arguments, got ${rest.length}`);
89
+ if (values.after === undefined)
90
+ return usageError("plan needs --after <lockfile>");
91
+ const read = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
92
+ const load = (path) => {
93
+ try {
94
+ return read(path);
95
+ }
96
+ catch (error) {
97
+ return usageError(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
98
+ }
99
+ };
100
+ const after = load(values.after);
101
+ if (typeof after !== "string")
102
+ return after;
103
+ let before;
104
+ if (values.installed !== undefined && values.before !== undefined)
105
+ return usageError("plan takes --before or --installed, not both");
106
+ if (values.installed !== undefined) {
107
+ const tryRead = (path) => {
108
+ try {
109
+ return read(path);
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ };
115
+ before = isPnpmLockfile(after)
116
+ ? installedPnpmLockfile(values.installed, tryRead, (dir) => {
117
+ try {
118
+ return readdirSync(dir);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }, parsePnpmLockfile)
124
+ : installedLockfile(values.installed, tryRead);
125
+ }
126
+ else if (values.before !== undefined && values.before !== "none") {
127
+ const text = load(values.before);
128
+ if (typeof text !== "string")
129
+ return text;
130
+ before = text;
131
+ }
132
+ const buildsDir = values["pnpm-builds"];
133
+ const builds = buildsDir === undefined || values["pnpm-version"] === undefined
134
+ ? undefined
135
+ : pnpmBuildPolicy({
136
+ packageJson: (() => {
137
+ try {
138
+ return read(`${buildsDir}/package.json`);
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ })(),
144
+ workspaceYaml: (() => {
145
+ try {
146
+ return read(`${buildsDir}/pnpm-workspace.yaml`);
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
151
+ })(),
152
+ }, values["pnpm-version"]);
153
+ const outcome = values.offline === true
154
+ ? planInstall(before, after)
155
+ : await planInstallOnline(before, after, {
156
+ ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
157
+ ...(deps.cache === undefined || values["no-cache"] === true
158
+ ? {}
159
+ : { cache: deps.cache }),
160
+ content: values["metadata-only"] !== true,
161
+ ...(builds === undefined ? {} : { builds }),
162
+ ...(values.llm === true && deps.llm !== undefined
163
+ ? { llm: deps.llm }
164
+ : {}),
165
+ });
166
+ const llmMissing = values.llm === true && deps.llm === undefined && values.offline !== true
167
+ ? [
168
+ {
169
+ ruleId: "llm/triage",
170
+ severity: "info",
171
+ file: "croak",
172
+ message: "--llm was asked for, but ANTHROPIC_API_KEY is not set: rules only",
173
+ },
174
+ ]
175
+ : [];
176
+ const tryRead = (path) => {
177
+ try {
178
+ return read(path);
179
+ }
180
+ catch {
181
+ return undefined;
182
+ }
183
+ };
184
+ const projectFs = {
185
+ read: tryRead,
186
+ kind: (path) => {
187
+ try {
188
+ const stats = lstatSync(path);
189
+ const kind = stats.isSymbolicLink()
190
+ ? "symlink"
191
+ : stats.isFile()
192
+ ? "file"
193
+ : "other";
194
+ return { kind, size: stats.size };
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ },
200
+ };
201
+ const project = values.project;
202
+ const extra = [
203
+ ...llmMissing,
204
+ ...(buildsDir === undefined ? [] : pnpmfileFindings(buildsDir, projectFs)),
205
+ ...(project === undefined || outcome.status !== "complete"
206
+ ? []
207
+ : projectScriptFindings(project, projectFs, {
208
+ members: workspaceMembers(after),
209
+ pnpm: buildsDir !== undefined,
210
+ })),
211
+ ];
212
+ const member = values.member;
213
+ const notMember = member !== undefined &&
214
+ outcome.status === "complete" &&
215
+ !workspaceMembers(after).includes(posix.normalize(member));
216
+ const judged = notMember
217
+ ? {
218
+ status: "incomplete",
219
+ reason: `${member} is inside a workspace that does not list it as a member, so the install would not run where croak planned it`,
220
+ durationMs: outcome.durationMs,
221
+ }
222
+ : outcome.status !== "complete" || extra.length === 0
223
+ ? outcome
224
+ : (() => {
225
+ const findings = [...extra, ...outcome.findings];
226
+ return { ...outcome, findings, verdict: aggregate(findings) };
227
+ })();
228
+ const stdout = values.json === true
229
+ ? planToJson(judged)
230
+ : renderPlan(judged, { emoji: deps.emoji && values.plain !== true });
231
+ return { code: exitCode(judged), stdout, stderr: "" };
232
+ }
233
+ export async function run(argv, deps = DEFAULT_DEPS) {
234
+ if (argv[0] === "sanitize") {
235
+ const [, kind, file] = argv;
236
+ if (file === undefined ||
237
+ (kind !== "root-manifest" && kind !== "pnpm-workspace"))
238
+ return usageError("usage: croak sanitize root-manifest|pnpm-workspace <file>");
239
+ const read = deps.readFile ?? ((path) => readFileSync(path, "utf8"));
240
+ try {
241
+ const text = read(file);
242
+ return {
243
+ code: EXIT.ok,
244
+ stdout: kind === "root-manifest"
245
+ ? sanitizeRootManifest(text)
246
+ : sanitizePnpmWorkspace(text),
247
+ stderr: "",
248
+ };
249
+ }
250
+ catch (error) {
251
+ if (error instanceof SanitizeError)
252
+ return usageError(error.message);
253
+ throw error;
254
+ }
255
+ }
256
+ if (argv[0] === "workspace-root") {
257
+ const [, manager, dir] = argv;
258
+ if (dir === undefined || (manager !== "npm" && manager !== "pnpm"))
259
+ return usageError("usage: croak workspace-root npm|pnpm <dir>");
260
+ const root = workspaceRoot(dir, manager, {
261
+ exists: existsSync,
262
+ readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
263
+ });
264
+ return {
265
+ code: EXIT.ok,
266
+ stdout: root === undefined ? "" : `${root}\n`,
267
+ stderr: "",
268
+ };
269
+ }
270
+ if (argv[0] === "suggest") {
271
+ const lines = argv.slice(1).flatMap((spec) => {
272
+ const names = suggestNames(decodeURIComponent(spec));
273
+ return names.length === 0
274
+ ? []
275
+ : [
276
+ ` ${spec}: did you mean ${names.map((n) => `\`${n}\``).join(", ")}?\n`,
277
+ ];
278
+ });
279
+ return { code: EXIT.ok, stdout: lines.join(""), stderr: "" };
280
+ }
281
+ if (argv[0] === "hook")
282
+ return runHook(argv.slice(1), {
283
+ readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
284
+ exists: existsSync,
285
+ home: homedir(),
286
+ env: process.env,
287
+ });
288
+ let parsed;
289
+ try {
290
+ parsed = parse(argv);
291
+ }
292
+ catch (error) {
293
+ if (!isParseArgsError(error))
294
+ throw error;
295
+ return usageError(error.message);
296
+ }
297
+ const { values, positionals } = parsed;
298
+ if (values.version === true)
299
+ return { code: EXIT.ok, stdout: `${VERSION}\n`, stderr: "" };
300
+ const [command, ...rest] = positionals;
301
+ if (values.help === true || command === undefined) {
302
+ return { code: EXIT.ok, stdout: HELP, stderr: "" };
303
+ }
304
+ if (command === "plan")
305
+ return await runPlan(values, rest, deps);
306
+ if (command !== "repo")
307
+ return usageError(`unknown command "${command}"`);
308
+ if (rest.length > 1)
309
+ return usageError(`repo takes one path, got ${rest.length}`);
310
+ let outcome;
311
+ try {
312
+ outcome = deps.scan(rest[0] ?? ".");
313
+ }
314
+ catch (error) {
315
+ if (!(error instanceof ScanTargetError))
316
+ throw error;
317
+ return usageError(error.message);
318
+ }
319
+ const stdout = values.json === true
320
+ ? scanToJson(outcome)
321
+ : renderScan(outcome, { emoji: deps.emoji && values.plain !== true });
322
+ return { code: exitCode(outcome), stdout, stderr: "" };
323
+ }
324
+ if (import.meta.main) {
325
+ let cache;
326
+ const contentCache = () => {
327
+ cache ??= fileContentCache(join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "croak", "content"), codeFingerprint(import.meta.dirname));
328
+ return cache;
329
+ };
330
+ const key = process.env.ANTHROPIC_API_KEY;
331
+ const result = await run(process.argv.slice(2), {
332
+ scan: (root) => scanRepoAt(root),
333
+ emoji: process.stdout.isTTY === true,
334
+ ...(key === undefined || key === ""
335
+ ? {}
336
+ : {
337
+ llm: { model: DEFAULT_MODEL, triage: anthropicTriage(key) },
338
+ }),
339
+ cache: {
340
+ get: (integrity, label) => contentCache().get(integrity, label),
341
+ set: (integrity, label, findings) => contentCache().set(integrity, label, findings),
342
+ },
343
+ });
344
+ process.stdout.write(result.stdout);
345
+ process.stderr.write(result.stderr);
346
+ process.exitCode = result.code;
347
+ }